migrate organizations from clerk to in-house
This commit is contained in:
@@ -18,25 +18,21 @@ export default function LayoutOrganizationSelector({
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const organization = organizations.find(
|
const organization = organizations.find(
|
||||||
(item) => item.slug === params.organizationSlug
|
(item) => item.id === params.organizationSlug
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!organization) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Combobox
|
<Combobox
|
||||||
className="w-full"
|
className="w-full"
|
||||||
placeholder="Select organization"
|
placeholder="Select organization"
|
||||||
icon={Building}
|
icon={Building}
|
||||||
value={organization.slug}
|
value={organization?.id}
|
||||||
items={
|
items={
|
||||||
organizations
|
organizations
|
||||||
.filter((item) => item.slug)
|
.filter((item) => item.id)
|
||||||
.map((item) => ({
|
.map((item) => ({
|
||||||
label: item.name,
|
label: item.name,
|
||||||
value: item.slug,
|
value: item.id,
|
||||||
})) ?? []
|
})) ?? []
|
||||||
}
|
}
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export default async function AppLayout({
|
|||||||
getDashboardsByProjectId(projectId),
|
getDashboardsByProjectId(projectId),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!organizations.find((item) => item.slug === organizationSlug)) {
|
if (!organizations.find((item) => item.id === organizationSlug)) {
|
||||||
return (
|
return (
|
||||||
<FullPageEmptyState title="Not found" className="min-h-screen">
|
<FullPageEmptyState title="Not found" className="min-h-screen">
|
||||||
The organization you were looking for could not be found.
|
The organization you were looking for could not be found.
|
||||||
|
|||||||
@@ -53,6 +53,9 @@ export default function CreateInvite({ projects }: Props) {
|
|||||||
closeSheet();
|
closeSheet();
|
||||||
router.refresh();
|
router.refresh();
|
||||||
},
|
},
|
||||||
|
onError() {
|
||||||
|
toast.error('Failed to invite user');
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Dot } from '@/components/dot';
|
|
||||||
import { TooltipComplete } from '@/components/tooltip-complete';
|
import { TooltipComplete } from '@/components/tooltip-complete';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -20,9 +19,9 @@ import {
|
|||||||
} from '@/components/ui/table';
|
} from '@/components/ui/table';
|
||||||
import { Widget, WidgetHead } from '@/components/widget';
|
import { Widget, WidgetHead } from '@/components/widget';
|
||||||
import { api } from '@/trpc/client';
|
import { api } from '@/trpc/client';
|
||||||
import { cn } from '@/utils/cn';
|
|
||||||
import { MoreHorizontalIcon } from 'lucide-react';
|
import { MoreHorizontalIcon } from 'lucide-react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { pathOr } from 'ramda';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
import type { IServiceInvite, IServiceProject } from '@openpanel/db';
|
import type { IServiceInvite, IServiceProject } from '@openpanel/db';
|
||||||
@@ -44,10 +43,9 @@ const Invites = ({ invites, projects }: Props) => {
|
|||||||
<Table className="mini">
|
<Table className="mini">
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>Name</TableHead>
|
<TableHead>Mail</TableHead>
|
||||||
<TableHead>Role</TableHead>
|
<TableHead>Role</TableHead>
|
||||||
<TableHead>Created</TableHead>
|
<TableHead>Created</TableHead>
|
||||||
<TableHead>Status</TableHead>
|
|
||||||
<TableHead>Access</TableHead>
|
<TableHead>Access</TableHead>
|
||||||
<TableHead>More</TableHead>
|
<TableHead>More</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -66,18 +64,9 @@ interface ItemProps extends IServiceInvite {
|
|||||||
projects: IServiceProject[];
|
projects: IServiceProject[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function Item({
|
function Item({ id, email, role, createdAt, projects, meta }: ItemProps) {
|
||||||
id,
|
|
||||||
email,
|
|
||||||
role,
|
|
||||||
createdAt,
|
|
||||||
projects,
|
|
||||||
publicMetadata,
|
|
||||||
status,
|
|
||||||
organizationId,
|
|
||||||
}: ItemProps) {
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const access = (publicMetadata?.access ?? []) as string[];
|
const access = pathOr<string[]>([], ['access'], meta);
|
||||||
const revoke = api.organization.revokeInvite.useMutation({
|
const revoke = api.organization.revokeInvite.useMutation({
|
||||||
onSuccess() {
|
onSuccess() {
|
||||||
toast.success(`Invite for ${email} revoked`);
|
toast.success(`Invite for ${email} revoked`);
|
||||||
@@ -96,17 +85,6 @@ function Item({
|
|||||||
{new Date(createdAt).toLocaleDateString()}
|
{new Date(createdAt).toLocaleDateString()}
|
||||||
</TooltipComplete>
|
</TooltipComplete>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="flex items-center gap-2 capitalize">
|
|
||||||
<Dot
|
|
||||||
className={cn(
|
|
||||||
status === 'accepted' && 'bg-emerald-600',
|
|
||||||
status === 'revoked' && 'bg-red-600',
|
|
||||||
status === 'pending' && 'bg-orange-600'
|
|
||||||
)}
|
|
||||||
animated={status === 'pending'}
|
|
||||||
/>
|
|
||||||
{status}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{access.map((id) => {
|
{access.map((id) => {
|
||||||
const project = projects.find((p) => p.id === id);
|
const project = projects.find((p) => p.id === id);
|
||||||
@@ -136,7 +114,7 @@ function Item({
|
|||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="text-destructive"
|
className="text-destructive"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
revoke.mutate({ organizationId, invitationId: id });
|
revoke.mutate({ memberId: id });
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Revoke invite
|
Revoke invite
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { TooltipComplete } from '@/components/tooltip-complete';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { ComboboxAdvanced } from '@/components/ui/combobox-advanced';
|
import { ComboboxAdvanced } from '@/components/ui/combobox-advanced';
|
||||||
import {
|
import {
|
||||||
@@ -62,10 +63,10 @@ interface ItemProps extends IServiceMember {
|
|||||||
|
|
||||||
function Item({
|
function Item({
|
||||||
id,
|
id,
|
||||||
name,
|
user,
|
||||||
role,
|
role,
|
||||||
|
organizationId,
|
||||||
createdAt,
|
createdAt,
|
||||||
organization,
|
|
||||||
projects,
|
projects,
|
||||||
access: prevAccess,
|
access: prevAccess,
|
||||||
}: ItemProps) {
|
}: ItemProps) {
|
||||||
@@ -73,22 +74,35 @@ function Item({
|
|||||||
const mutation = api.organization.updateMemberAccess.useMutation();
|
const mutation = api.organization.updateMemberAccess.useMutation();
|
||||||
const revoke = api.organization.removeMember.useMutation({
|
const revoke = api.organization.removeMember.useMutation({
|
||||||
onSuccess() {
|
onSuccess() {
|
||||||
toast.success(`${name} has been removed from the organization`);
|
toast.success(
|
||||||
|
`${user?.firstName} has been removed from the organization`
|
||||||
|
);
|
||||||
router.refresh();
|
router.refresh();
|
||||||
},
|
},
|
||||||
onError() {
|
onError() {
|
||||||
toast.error(`Failed to remove ${name} from the organization`);
|
toast.error(`Failed to remove ${user?.firstName} from the organization`);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const [access, setAccess] = useState<string[]>(
|
const [access, setAccess] = useState<string[]>(
|
||||||
prevAccess.map((item) => item.projectId)
|
prevAccess.map((item) => item.projectId)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableRow key={id}>
|
<TableRow key={id}>
|
||||||
<TableCell className="font-medium">{name}</TableCell>
|
<TableCell className="font-medium">
|
||||||
|
<div>{[user?.firstName, user?.lastName].filter(Boolean).join(' ')}</div>
|
||||||
|
<div className="text-sm text-muted-foreground">{user?.email}</div>
|
||||||
|
</TableCell>
|
||||||
<TableCell>{role}</TableCell>
|
<TableCell>{role}</TableCell>
|
||||||
<TableCell>{new Date(createdAt).toLocaleString()}</TableCell>
|
<TableCell>
|
||||||
|
<TooltipComplete content={new Date(createdAt).toLocaleString()}>
|
||||||
|
{new Date(createdAt).toLocaleDateString()}
|
||||||
|
</TooltipComplete>
|
||||||
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<ComboboxAdvanced
|
<ComboboxAdvanced
|
||||||
placeholder="Restrict access to projects"
|
placeholder="Restrict access to projects"
|
||||||
@@ -96,8 +110,8 @@ function Item({
|
|||||||
onChange={(newAccess) => {
|
onChange={(newAccess) => {
|
||||||
setAccess(newAccess);
|
setAccess(newAccess);
|
||||||
mutation.mutate({
|
mutation.mutate({
|
||||||
userId: id!,
|
userId: user.id,
|
||||||
organizationSlug: organization.slug,
|
organizationSlug: organizationId,
|
||||||
access: newAccess as string[],
|
access: newAccess as string[],
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
@@ -116,7 +130,7 @@ function Item({
|
|||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="text-destructive"
|
className="text-destructive"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
revoke.mutate({ organizationId: organization.id, userId: id! });
|
revoke.mutate({ organizationId: organizationId, userId: id });
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Remove member
|
Remove member
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import PageLayout from '@/app/(app)/[organizationSlug]/[projectId]/page-layout';
|
import PageLayout from '@/app/(app)/[organizationSlug]/[projectId]/page-layout';
|
||||||
import { FullPageEmptyState } from '@/components/full-page-empty-state';
|
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 { ShieldAlertIcon } from 'lucide-react';
|
||||||
import { notFound } from 'next/navigation';
|
import { notFound } from 'next/navigation';
|
||||||
|
|
||||||
import { getOrganizationBySlug } from '@openpanel/db';
|
import { db } from '@openpanel/db';
|
||||||
|
|
||||||
import EditOrganization from './edit-organization';
|
import EditOrganization from './edit-organization';
|
||||||
import InvitesServer from './invites';
|
import InvitesServer from './invites';
|
||||||
@@ -19,25 +19,30 @@ interface PageProps {
|
|||||||
export default async function Page({
|
export default async function Page({
|
||||||
params: { organizationSlug },
|
params: { organizationSlug },
|
||||||
}: PageProps) {
|
}: PageProps) {
|
||||||
const organization = await getOrganizationBySlug(organizationSlug);
|
|
||||||
const session = auth();
|
const session = auth();
|
||||||
const memberships = await clerkClient.users.getOrganizationMembershipList({
|
const organization = await db.organization.findUnique({
|
||||||
userId: session.userId!,
|
where: {
|
||||||
|
id: organizationSlug,
|
||||||
|
members: {
|
||||||
|
some: {
|
||||||
|
userId: session.userId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
members: {
|
||||||
|
select: {
|
||||||
|
role: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!organization) {
|
if (!organization) {
|
||||||
return notFound();
|
return notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
const member = memberships.data.find(
|
const hasAccess = organization.members[0]?.role === 'org:admin';
|
||||||
(membership) => membership.organization.id === organization.id
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!member) {
|
|
||||||
return notFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasAccess = member.role === 'org:admin';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -20,9 +20,7 @@ export default async function Page({
|
|||||||
getCurrentProjects(organizationSlug),
|
getCurrentProjects(organizationSlug),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const organization = organizations.find(
|
const organization = organizations.find((org) => org.id === organizationSlug);
|
||||||
(org) => org.slug === organizationSlug
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!organization) {
|
if (!organization) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export default async function Page() {
|
|||||||
const organizations = await getCurrentOrganizations();
|
const organizations = await getCurrentOrganizations();
|
||||||
|
|
||||||
if (organizations.length > 0) {
|
if (organizations.length > 0) {
|
||||||
return redirect(`/${organizations[0]?.slug}`);
|
return redirect(`/${organizations[0]?.id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return redirect('/onboarding');
|
return redirect('/onboarding');
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ type Props = {
|
|||||||
|
|
||||||
const Connect = async ({ params: { projectId } }: Props) => {
|
const Connect = async ({ params: { projectId } }: Props) => {
|
||||||
const orgs = await getCurrentOrganizations();
|
const orgs = await getCurrentOrganizations();
|
||||||
const organizationSlug = orgs[0]?.slug;
|
const organizationSlug = orgs[0]?.id;
|
||||||
if (!organizationSlug) {
|
if (!organizationSlug) {
|
||||||
throw new Error('No organization found');
|
throw new Error('No organization found');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ type Props = {
|
|||||||
|
|
||||||
const Verify = async ({ params: { projectId } }: Props) => {
|
const Verify = async ({ params: { projectId } }: Props) => {
|
||||||
const orgs = await getCurrentOrganizations();
|
const orgs = await getCurrentOrganizations();
|
||||||
const organizationSlug = orgs[0]?.slug;
|
const organizationSlug = orgs[0]?.id;
|
||||||
if (!organizationSlug) {
|
if (!organizationSlug) {
|
||||||
throw new Error('No organization found');
|
throw new Error('No organization found');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -112,10 +112,10 @@ const Tracking = ({
|
|||||||
value={field.value}
|
value={field.value}
|
||||||
items={
|
items={
|
||||||
organizations
|
organizations
|
||||||
.filter((item) => item.slug)
|
.filter((item) => item.id)
|
||||||
.map((item) => ({
|
.map((item) => ({
|
||||||
label: item.name,
|
label: item.name,
|
||||||
value: item.slug,
|
value: item.id,
|
||||||
})) ?? []
|
})) ?? []
|
||||||
}
|
}
|
||||||
onChange={field.onChange}
|
onChange={field.onChange}
|
||||||
|
|||||||
@@ -1,10 +1,64 @@
|
|||||||
import type { WebhookEvent } from '@clerk/nextjs/server';
|
import type { WebhookEvent } from '@clerk/nextjs/server';
|
||||||
|
import { pathOr } from 'ramda';
|
||||||
|
|
||||||
import { AccessLevel, db } from '@openpanel/db';
|
import { AccessLevel, db } from '@openpanel/db';
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
const payload: WebhookEvent = await request.json();
|
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<string[]>([], ['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') {
|
if (payload.type === 'organizationMembership.created') {
|
||||||
const access = payload.data.public_metadata.access;
|
const access = payload.data.public_metadata.access;
|
||||||
if (Array.isArray(access)) {
|
if (Array.isArray(access)) {
|
||||||
@@ -12,7 +66,7 @@ export async function POST(request: Request) {
|
|||||||
data: access
|
data: access
|
||||||
.filter((a): a is string => typeof a === 'string')
|
.filter((a): a is string => typeof a === 'string')
|
||||||
.map((projectId) => ({
|
.map((projectId) => ({
|
||||||
organizationSlug: payload.data.organization.slug!,
|
organizationSlug: payload.data.organization.slug,
|
||||||
projectId: projectId,
|
projectId: projectId,
|
||||||
userId: payload.data.public_user_data.user_id,
|
userId: payload.data.public_user_data.user_id,
|
||||||
level: AccessLevel.read,
|
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') {
|
if (payload.type === 'organizationMembership.deleted') {
|
||||||
await db.projectAccess.deleteMany({
|
await db.projectAccess.deleteMany({
|
||||||
where: {
|
where: {
|
||||||
organizationSlug: payload.data.organization.slug!,
|
organizationSlug: payload.data.organization.slug,
|
||||||
userId: payload.data.public_user_data.user_id,
|
userId: payload.data.public_user_data.user_id,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "users" ADD COLUMN "deletedAt" TIMESTAMP(3);
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -17,10 +17,63 @@ enum ProjectType {
|
|||||||
backend
|
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 {
|
model Project {
|
||||||
id String @id @default(dbgenerated("gen_random_uuid()"))
|
id String @id @default(dbgenerated("gen_random_uuid()"))
|
||||||
name String
|
name String
|
||||||
organizationSlug String
|
organizationSlug String
|
||||||
|
organization Organization? @relation(fields: [organizationId], references: [id])
|
||||||
|
organizationId String?
|
||||||
eventsCount Int @default(0)
|
eventsCount Int @default(0)
|
||||||
types ProjectType[] @default([])
|
types ProjectType[] @default([])
|
||||||
|
|
||||||
@@ -51,7 +104,10 @@ model ProjectAccess {
|
|||||||
projectId String
|
projectId String
|
||||||
project Project @relation(fields: [projectId], references: [id])
|
project Project @relation(fields: [projectId], references: [id])
|
||||||
organizationSlug String
|
organizationSlug String
|
||||||
|
organization Organization? @relation(fields: [organizationId], references: [id])
|
||||||
|
organizationId String?
|
||||||
userId String
|
userId String
|
||||||
|
user User @relation(fields: [userId], references: [id])
|
||||||
level AccessLevel
|
level AccessLevel
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @default(now()) @updatedAt
|
updatedAt DateTime @default(now()) @updatedAt
|
||||||
@@ -112,6 +168,8 @@ model Client {
|
|||||||
projectId String?
|
projectId String?
|
||||||
project Project? @relation(fields: [projectId], references: [id])
|
project Project? @relation(fields: [projectId], references: [id])
|
||||||
organizationSlug String
|
organizationSlug String
|
||||||
|
organization Organization? @relation(fields: [organizationId], references: [id])
|
||||||
|
organizationId String?
|
||||||
cors String?
|
cors String?
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
@@ -142,6 +200,8 @@ model Dashboard {
|
|||||||
id String @id @default(dbgenerated("gen_random_uuid()"))
|
id String @id @default(dbgenerated("gen_random_uuid()"))
|
||||||
name String
|
name String
|
||||||
organizationSlug String
|
organizationSlug String
|
||||||
|
organization Organization? @relation(fields: [organizationId], references: [id])
|
||||||
|
organizationId String?
|
||||||
projectId String
|
projectId String
|
||||||
project Project @relation(fields: [projectId], references: [id])
|
project Project @relation(fields: [projectId], references: [id])
|
||||||
reports Report[]
|
reports Report[]
|
||||||
@@ -199,6 +259,8 @@ model ShareOverview {
|
|||||||
projectId String @unique
|
projectId String @unique
|
||||||
project Project @relation(fields: [projectId], references: [id])
|
project Project @relation(fields: [projectId], references: [id])
|
||||||
organizationSlug String
|
organizationSlug String
|
||||||
|
organization Organization? @relation(fields: [organizationId], references: [id])
|
||||||
|
organizationId String?
|
||||||
public Boolean @default(false)
|
public Boolean @default(false)
|
||||||
password String?
|
password String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|||||||
@@ -1,24 +1,22 @@
|
|||||||
import type {
|
import { auth } from '@clerk/nextjs/server';
|
||||||
Organization,
|
|
||||||
OrganizationInvitation,
|
|
||||||
OrganizationMembership,
|
|
||||||
} from '@clerk/nextjs/dist/types/server';
|
|
||||||
import { auth, clerkClient } from '@clerk/nextjs/server';
|
|
||||||
import { sort } from 'ramda';
|
|
||||||
|
|
||||||
import type { ProjectAccess } from '../prisma-client';
|
import type { Organization, Prisma, ProjectAccess } from '../prisma-client';
|
||||||
import { db } from '../prisma-client';
|
import { db } from '../prisma-client';
|
||||||
|
|
||||||
export type IServiceOrganization = ReturnType<typeof transformOrganization>;
|
export type IServiceOrganization = ReturnType<typeof transformOrganization>;
|
||||||
export type IServiceInvite = ReturnType<typeof transformInvite>;
|
export type IServiceInvite = Prisma.MemberGetPayload<{
|
||||||
export type IServiceMember = ReturnType<typeof transformMember>;
|
include: { user: true };
|
||||||
|
}>;
|
||||||
|
export type IServiceMember = Prisma.MemberGetPayload<{
|
||||||
|
include: { user: true };
|
||||||
|
}> & { access: ProjectAccess[] };
|
||||||
export type IServiceProjectAccess = ProjectAccess;
|
export type IServiceProjectAccess = ProjectAccess;
|
||||||
|
|
||||||
export function transformOrganization(org: Organization) {
|
export function transformOrganization(org: Organization) {
|
||||||
return {
|
return {
|
||||||
id: org.id,
|
id: org.id,
|
||||||
|
slug: org.id,
|
||||||
name: org.name,
|
name: org.name,
|
||||||
slug: org.slug!,
|
|
||||||
createdAt: org.createdAt,
|
createdAt: org.createdAt,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -26,20 +24,28 @@ export function transformOrganization(org: Organization) {
|
|||||||
export async function getCurrentOrganizations() {
|
export async function getCurrentOrganizations() {
|
||||||
const session = auth();
|
const session = auth();
|
||||||
if (!session.userId) return [];
|
if (!session.userId) return [];
|
||||||
const organizations = await clerkClient.users.getOrganizationMembershipList({
|
const organizations = await db.organization.findMany({
|
||||||
|
where: {
|
||||||
|
members: {
|
||||||
|
some: {
|
||||||
userId: session.userId,
|
userId: session.userId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
createdAt: 'desc',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
return sort(
|
|
||||||
(a, b) => a.createdAt - b.createdAt,
|
return organizations.map(transformOrganization);
|
||||||
organizations.data.map((item) => transformOrganization(item.organization))
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getOrganizationBySlug(slug: string) {
|
export function getOrganizationBySlug(slug: string) {
|
||||||
return clerkClient.organizations
|
return db.organization.findUnique({
|
||||||
.getOrganization({ slug })
|
where: {
|
||||||
.then(transformOrganization)
|
id: slug,
|
||||||
.catch(() => null);
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getOrganizationByProjectId(projectId: string) {
|
export async function getOrganizationByProjectId(projectId: string) {
|
||||||
@@ -47,63 +53,42 @@ export async function getOrganizationByProjectId(projectId: string) {
|
|||||||
where: {
|
where: {
|
||||||
id: projectId,
|
id: projectId,
|
||||||
},
|
},
|
||||||
|
include: {
|
||||||
|
organization: true,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return clerkClient.organizations.getOrganization({
|
if (!project.organization) {
|
||||||
slug: project.organizationSlug,
|
return null;
|
||||||
});
|
}
|
||||||
}
|
|
||||||
|
|
||||||
export function transformInvite(invite: OrganizationInvitation) {
|
return transformOrganization(project.organization);
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getInvites(organizationSlug: string) {
|
export async function getInvites(organizationSlug: string) {
|
||||||
const org = await getOrganizationBySlug(organizationSlug);
|
return db.member.findMany({
|
||||||
if (!org) return [];
|
where: {
|
||||||
return await clerkClient.organizations
|
organizationId: organizationSlug,
|
||||||
.getOrganizationInvitationList({
|
userId: null,
|
||||||
organizationId: org.id,
|
},
|
||||||
})
|
include: {
|
||||||
.then((invites) => invites.data.map(transformInvite));
|
user: true,
|
||||||
}
|
},
|
||||||
|
});
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getMembers(organizationSlug: string) {
|
export async function getMembers(organizationSlug: string) {
|
||||||
const org = await getOrganizationBySlug(organizationSlug);
|
|
||||||
if (!org) return [];
|
|
||||||
const [members, access] = await Promise.all([
|
const [members, access] = await Promise.all([
|
||||||
clerkClient.organizations.getOrganizationMembershipList({
|
db.member.findMany({
|
||||||
organizationId: org.id,
|
where: {
|
||||||
|
organizationId: organizationSlug,
|
||||||
|
userId: {
|
||||||
|
not: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
user: true,
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
db.projectAccess.findMany({
|
db.projectAccess.findMany({
|
||||||
where: {
|
where: {
|
||||||
@@ -112,22 +97,17 @@ export async function getMembers(organizationSlug: string) {
|
|||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return members.data
|
return members.map((member) => ({
|
||||||
.map((member) => {
|
|
||||||
const projectAccess = access.filter(
|
|
||||||
(item) => item.userId === member.publicUserData?.userId
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
...member,
|
...member,
|
||||||
access: projectAccess,
|
access: access.filter((a) => a.userId === member.userId),
|
||||||
};
|
}));
|
||||||
})
|
|
||||||
.map(transformMember);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getMember(organizationSlug: string, userId: string) {
|
export async function getMember(organizationSlug: string, userId: string) {
|
||||||
const org = await getOrganizationBySlug(organizationSlug);
|
return db.member.findFirst({
|
||||||
if (!org) return null;
|
where: {
|
||||||
const members = await getMembers(org.id);
|
organizationId: organizationSlug,
|
||||||
return members.find((member) => member.id === userId) ?? null;
|
userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,15 +58,34 @@ export async function getCurrentProjects(organizationSlug: string) {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return db.project.findMany({
|
const [projects, members, access] = await Promise.all([
|
||||||
|
db.project.findMany({
|
||||||
where: {
|
where: {
|
||||||
organizationSlug,
|
organizationSlug,
|
||||||
},
|
},
|
||||||
include: {
|
}),
|
||||||
access: true,
|
db.member.findMany({
|
||||||
|
where: {
|
||||||
|
userId: session.userId,
|
||||||
|
organizationId: organizationSlug,
|
||||||
},
|
},
|
||||||
orderBy: {
|
}),
|
||||||
eventsCount: 'desc',
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,7 @@
|
|||||||
import type { User } from '@clerk/nextjs/dist/types/server';
|
import { auth } from '@clerk/nextjs/server';
|
||||||
import { auth, clerkClient } from '@clerk/nextjs/server';
|
|
||||||
|
|
||||||
import { db } from '../prisma-client';
|
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() {
|
export async function getCurrentUser() {
|
||||||
const session = auth();
|
const session = auth();
|
||||||
if (!session.userId) {
|
if (!session.userId) {
|
||||||
@@ -22,20 +11,9 @@ export async function getCurrentUser() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getUserById(id: string) {
|
export async function getUserById(id: string) {
|
||||||
return clerkClient.users.getUser(id).then(transformUser);
|
return db.user.findUnique({
|
||||||
}
|
|
||||||
|
|
||||||
export async function isWaitlistUserAccepted() {
|
|
||||||
const user = await getCurrentUser();
|
|
||||||
const waitlist = await db.waitlist.findFirst({
|
|
||||||
where: {
|
where: {
|
||||||
email: user?.email,
|
id,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!waitlist) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return waitlist.accepted;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { clerkClient } from '@clerk/fastify';
|
import { clerkClient } from '@clerk/fastify';
|
||||||
|
|
||||||
import { getProjectById } from '@openpanel/db';
|
import { db, getProjectById } from '@openpanel/db';
|
||||||
import { cacheable } from '@openpanel/redis';
|
import { cacheable } from '@openpanel/redis';
|
||||||
|
|
||||||
export const getProjectAccessCached = cacheable(getProjectAccess, 60 * 60);
|
export const getProjectAccessCached = cacheable(getProjectAccess, 60 * 60);
|
||||||
@@ -13,20 +13,19 @@ export async function getProjectAccess({
|
|||||||
}) {
|
}) {
|
||||||
try {
|
try {
|
||||||
// Check if user has access to the project
|
// Check if user has access to the project
|
||||||
const [project, organizations] = await Promise.all([
|
const project = await getProjectById(projectId);
|
||||||
getProjectById(projectId),
|
if (!project?.organizationSlug) {
|
||||||
clerkClient.users.getOrganizationMembershipList({
|
|
||||||
userId,
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (!project) {
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return !!organizations.data.find(
|
const member = await db.member.findFirst({
|
||||||
(org) => org.organization.slug === project.organizationSlug
|
where: {
|
||||||
);
|
organizationId: project.organizationSlug,
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return member;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -43,11 +42,10 @@ export async function getOrganizationAccess({
|
|||||||
userId: string;
|
userId: string;
|
||||||
organizationId: string;
|
organizationId: string;
|
||||||
}) {
|
}) {
|
||||||
const organizations = await clerkClient.users.getOrganizationMembershipList({
|
return db.member.findFirst({
|
||||||
|
where: {
|
||||||
userId,
|
userId,
|
||||||
|
organizationId,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return !!organizations.data.find(
|
|
||||||
(org) => org.organization.id === organizationId
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
import { clerkClient } from '@clerk/fastify';
|
|
||||||
import type { z } from 'zod';
|
import type { z } from 'zod';
|
||||||
|
|
||||||
import { hashPassword, slug, stripTrailingSlash } from '@openpanel/common';
|
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 type { ProjectType } from '@openpanel/db';
|
||||||
import { zOnboardingProject } from '@openpanel/validation';
|
import { zOnboardingProject } from '@openpanel/validation';
|
||||||
|
|
||||||
@@ -14,16 +13,16 @@ async function createOrGetOrganization(
|
|||||||
userId: string
|
userId: string
|
||||||
) {
|
) {
|
||||||
if (input.organizationSlug) {
|
if (input.organizationSlug) {
|
||||||
return await clerkClient.organizations.getOrganization({
|
return await getOrganizationBySlug(input.organizationSlug);
|
||||||
slug: input.organizationSlug,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (input.organization) {
|
if (input.organization) {
|
||||||
return await clerkClient.organizations.createOrganization({
|
return db.organization.create({
|
||||||
|
data: {
|
||||||
|
id: slug(input.organization),
|
||||||
name: input.organization,
|
name: input.organization,
|
||||||
slug: slug(input.organization),
|
createdByUserId: userId,
|
||||||
createdBy: userId,
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,7 +43,7 @@ export const onboardingRouter = createTRPCRouter({
|
|||||||
ctx.session.userId
|
ctx.session.userId
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!organization?.slug) {
|
if (!organization?.id) {
|
||||||
throw new Error('Organization slug is missing');
|
throw new Error('Organization slug is missing');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,7 +51,7 @@ export const onboardingRouter = createTRPCRouter({
|
|||||||
data: {
|
data: {
|
||||||
id: await getId('project', input.project),
|
id: await getId('project', input.project),
|
||||||
name: input.project,
|
name: input.project,
|
||||||
organizationSlug: organization.slug,
|
organizationSlug: organization.id,
|
||||||
types,
|
types,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -61,7 +60,7 @@ export const onboardingRouter = createTRPCRouter({
|
|||||||
const client = await db.client.create({
|
const client = await db.client.create({
|
||||||
data: {
|
data: {
|
||||||
name: `${project.name} Client`,
|
name: `${project.name} Client`,
|
||||||
organizationSlug: organization.slug,
|
organizationSlug: organization.id,
|
||||||
projectId: project.id,
|
projectId: project.id,
|
||||||
type: 'write',
|
type: 'write',
|
||||||
cors: input.domain ? stripTrailingSlash(input.domain) : null,
|
cors: input.domain ? stripTrailingSlash(input.domain) : null,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { clerkClient } from '@clerk/fastify';
|
import { clerkClient } from '@clerk/fastify';
|
||||||
|
import { pathOr } from 'ramda';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { db, getOrganizationBySlug } from '@openpanel/db';
|
import { db, getOrganizationBySlug } from '@openpanel/db';
|
||||||
@@ -7,18 +8,6 @@ import { zInviteUser } from '@openpanel/validation';
|
|||||||
import { createTRPCRouter, protectedProcedure } from '../trpc';
|
import { createTRPCRouter, protectedProcedure } from '../trpc';
|
||||||
|
|
||||||
export const organizationRouter = createTRPCRouter({
|
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
|
update: protectedProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
@@ -27,42 +16,63 @@ export const organizationRouter = createTRPCRouter({
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
.mutation(({ input }) => {
|
.mutation(({ input }) => {
|
||||||
return clerkClient.organizations.updateOrganization(input.id, {
|
return db.organization.update({
|
||||||
|
where: {
|
||||||
|
id: input.id,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
name: input.name,
|
name: input.name,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
|
|
||||||
inviteUser: protectedProcedure
|
inviteUser: protectedProcedure
|
||||||
.input(zInviteUser)
|
.input(zInviteUser)
|
||||||
.mutation(async ({ input, ctx }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
const organization = await getOrganizationBySlug(input.organizationSlug);
|
const ticket = await clerkClient.invitations.createInvitation({
|
||||||
|
|
||||||
if (!organization) {
|
|
||||||
throw new Error('Organization not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
return clerkClient.organizations.createOrganizationInvitation({
|
|
||||||
organizationId: organization.id,
|
|
||||||
emailAddress: input.email,
|
emailAddress: input.email,
|
||||||
|
notify: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return db.member.create({
|
||||||
|
data: {
|
||||||
|
email: input.email,
|
||||||
|
organizationId: input.organizationSlug,
|
||||||
role: input.role,
|
role: input.role,
|
||||||
inviterUserId: ctx.session.userId,
|
invitedById: ctx.session.userId,
|
||||||
publicMetadata: {
|
meta: {
|
||||||
access: input.access,
|
access: input.access,
|
||||||
|
invitationId: ticket.id,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
revokeInvite: protectedProcedure
|
revokeInvite: protectedProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
organizationId: z.string(),
|
memberId: z.string(),
|
||||||
invitationId: z.string(),
|
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.mutation(async ({ input, ctx }) => {
|
.mutation(async ({ input }) => {
|
||||||
return clerkClient.organizations.revokeOrganizationInvitation({
|
const member = await db.member.findUniqueOrThrow({
|
||||||
organizationId: input.organizationId,
|
where: {
|
||||||
invitationId: input.invitationId,
|
id: input.memberId,
|
||||||
requestingUserId: ctx.session.userId,
|
},
|
||||||
|
});
|
||||||
|
const invitationId = pathOr<string | undefined>(
|
||||||
|
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) {
|
if (ctx.session.userId === input.userId) {
|
||||||
throw new Error('You cannot remove yourself from the organization');
|
throw new Error('You cannot remove yourself from the organization');
|
||||||
}
|
}
|
||||||
const organization = await clerkClient.organizations.getOrganization({
|
|
||||||
organizationId: input.organizationId,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!organization?.slug) {
|
await db.$transaction([
|
||||||
throw new Error('Organization not found');
|
db.member.deleteMany({
|
||||||
}
|
|
||||||
|
|
||||||
await db.projectAccess.deleteMany({
|
|
||||||
where: {
|
where: {
|
||||||
userId: input.userId,
|
userId: input.userId,
|
||||||
organizationSlug: organization.slug,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return clerkClient.organizations.deleteOrganizationMembership({
|
|
||||||
organizationId: input.organizationId,
|
organizationId: input.organizationId,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
db.projectAccess.deleteMany({
|
||||||
|
where: {
|
||||||
userId: input.userId,
|
userId: input.userId,
|
||||||
});
|
organizationSlug: input.organizationId,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
updateMemberAccess: protectedProcedure
|
updateMemberAccess: protectedProcedure
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { clerkClient } from '@clerk/fastify';
|
|
||||||
import { SeventySevenClient } from '@seventy-seven/sdk';
|
import { SeventySevenClient } from '@seventy-seven/sdk';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { getUserById } from '@openpanel/db';
|
||||||
|
|
||||||
import { createTRPCRouter, protectedProcedure } from '../trpc';
|
import { createTRPCRouter, protectedProcedure } from '../trpc';
|
||||||
|
|
||||||
const API_KEY = process.env.SEVENTY_SEVEN_API_KEY!;
|
const API_KEY = process.env.SEVENTY_SEVEN_API_KEY!;
|
||||||
@@ -21,14 +22,16 @@ export const ticketRouter = createTRPCRouter({
|
|||||||
throw new Error('Ticket system not configured');
|
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({
|
return client.createTicket({
|
||||||
subject: input.subject,
|
subject: input.subject,
|
||||||
body: input.body,
|
body: input.body,
|
||||||
meta: input.meta,
|
meta: input.meta,
|
||||||
senderEmail: user.primaryEmailAddress?.emailAddress || 'none',
|
senderEmail: user?.email || 'none',
|
||||||
senderFullName: user.fullName || 'none',
|
senderFullName: user?.firstName
|
||||||
|
? [user?.firstName, user?.lastName].filter(Boolean).join(' ')
|
||||||
|
: 'none',
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { clerkClient } from '@clerk/fastify';
|
import { clerkClient } from '@clerk/fastify';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { transformUser } from '@openpanel/db';
|
import { db } from '@openpanel/db';
|
||||||
|
|
||||||
import { createTRPCRouter, protectedProcedure } from '../trpc';
|
import { createTRPCRouter, protectedProcedure } from '../trpc';
|
||||||
|
|
||||||
@@ -13,12 +13,23 @@ export const userRouter = createTRPCRouter({
|
|||||||
lastName: z.string(),
|
lastName: z.string(),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.mutation(({ input, ctx }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
return clerkClient.users
|
const [updatedUser] = await Promise.all([
|
||||||
.updateUser(ctx.session.userId, {
|
db.user.update({
|
||||||
|
where: {
|
||||||
|
id: ctx.session.userId,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
firstName: input.firstName,
|
firstName: input.firstName,
|
||||||
lastName: input.lastName,
|
lastName: input.lastName,
|
||||||
})
|
},
|
||||||
.then(transformUser);
|
}),
|
||||||
|
clerkClient.users.updateUser(ctx.session.userId, {
|
||||||
|
firstName: input.firstName,
|
||||||
|
lastName: input.lastName,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return updatedUser;
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user