From 8a9532f21372f4a2aa5910ae4e45ad542be4731a Mon Sep 17 00:00:00 2001 From: Brian Cao Date: Thu, 9 Mar 2023 12:42:12 -0800 Subject: [PATCH] Feat/um 197 hook up teams (#1825) * Link up teams UI. * Fix auth order. * PR touchups. --- components/messages.js | 28 +++-- .../pages/settings/teams/TeamWebsites.js | 54 +++++++-- .../pages/settings/teams/TeamWebsitesTable.js | 103 ++++++++++++++++++ components/pages/settings/teams/TeamsList.js | 5 +- components/pages/settings/teams/TeamsTable.js | 64 ++++++----- .../settings/teams/WebsiteAddTeamForm.js | 62 +++++++++++ .../pages/settings/teams/WebsiteTags.js | 29 +++++ .../settings/teams/WebsiteTags.module.css | 11 ++ .../07_remove_user_id/migration.sql | 19 ++++ db/postgresql/schema.prisma | 10 +- lib/auth.ts | 29 ++++- pages/api/teamWebsites/[id].ts | 31 ++++++ pages/api/teams/[id]/websites.ts | 25 ++++- pages/api/teams/index.ts | 12 +- pages/api/teams/join.ts | 6 +- queries/admin/team.ts | 22 +--- queries/admin/teamWebsite.ts | 101 ++++++++++++++--- 17 files changed, 500 insertions(+), 111 deletions(-) create mode 100644 components/pages/settings/teams/TeamWebsitesTable.js create mode 100644 components/pages/settings/teams/WebsiteAddTeamForm.js create mode 100644 components/pages/settings/teams/WebsiteTags.js create mode 100644 components/pages/settings/teams/WebsiteTags.module.css create mode 100644 db/postgresql/migrations/07_remove_user_id/migration.sql create mode 100644 pages/api/teamWebsites/[id].ts diff --git a/components/messages.js b/components/messages.js index 608d4fbe..c09fcad3 100644 --- a/components/messages.js +++ b/components/messages.js @@ -46,6 +46,7 @@ export const labels = defineMessages({ deleteWebsite: { id: 'label.delete-website', defaultMessage: 'Delete website' }, reset: { id: 'label.reset', defaultMessage: 'Reset' }, addWebsite: { id: 'label.add-website', defaultMessage: 'Add website' }, + addWebsites: { id: 'label.add-websites', defaultMessage: 'Add websites' }, changePassword: { id: 'label.change-password', defaultMessage: 'Change password' }, currentPassword: { id: 'label.current-password', defaultMessage: 'Current password' }, newPassword: { id: 'label.new-password', defaultMessage: 'New password' }, @@ -145,6 +146,10 @@ export const messages = defineMessages({ id: 'message.reset-website', defaultMessage: 'To reset this website, type {confirmation} in the box below to confirm.', }, + websitesShared: { + id: 'message.shared-website', + defaultMessage: 'Websites can be viewed by the entire team.', + }, invalidDomain: { id: 'message.invalid-domain', defaultMessage: 'Invalid domain. Do not include http/https.', @@ -162,6 +167,14 @@ export const messages = defineMessages({ id: 'messages.no-websites', defaultMessage: 'You do not have any websites configured.', }, + noTeamWebsites: { + id: 'messages.no-team-websites', + defaultMessage: 'This team does not have any websites.', + }, + websitesAreShared: { + id: 'messages.websites-are-shared', + defaultMessage: 'Websites can be viewed by anyone on the team.', + }, noMatchPassword: { id: 'message.no-match-password', defaultMessage: 'Passwords do not match.' }, goToSettings: { id: 'message.go-to-settings', @@ -183,17 +196,6 @@ export const messages = defineMessages({ id: 'message.event-log', defaultMessage: '{event} on {url}', }, - newVersionAvailable: { - id: 'new-version-available', - defaultMessage: 'A new version of Umami {version} is available!', - }, -}); - -export const devices = defineMessages({ - desktop: { id: 'metrics.device.desktop', defaultMessage: 'Desktop' }, - laptop: { id: 'metrics.device.laptop', defaultMessage: 'Laptop' }, - tablet: { id: 'metrics.device.tablet', defaultMessage: 'Tablet' }, - mobile: { id: 'metrics.device.mobile', defaultMessage: 'Mobile' }, }); export function getMessage(id, formatMessage) { @@ -201,7 +203,3 @@ export function getMessage(id, formatMessage) { return message ? formatMessage(message) : id; } - -export function getDeviceMessage(device) { - return devices[device] || labels.unknown; -} diff --git a/components/pages/settings/teams/TeamWebsites.js b/components/pages/settings/teams/TeamWebsites.js index 1a04f490..494a6eb0 100644 --- a/components/pages/settings/teams/TeamWebsites.js +++ b/components/pages/settings/teams/TeamWebsites.js @@ -1,13 +1,26 @@ -import { Loading } from 'react-basics'; -import { useIntl } from 'react-intl'; +import EmptyPlaceholder from 'components/common/EmptyPlaceholder'; +import { labels, messages } from 'components/messages'; +import TeamWebsitesTable from 'components/pages/settings/teams/TeamWebsitesTable'; import useApi from 'hooks/useApi'; -import WebsitesTable from 'components/pages/settings/websites/WebsitesTable'; -import { messages } from 'components/messages'; +import { + ActionForm, + Button, + Icon, + Icons, + Loading, + Modal, + ModalTrigger, + Text, + useToast, +} from 'react-basics'; +import { useIntl } from 'react-intl'; +import WebsiteAddTeamForm from 'components/pages/settings/teams/WebsiteAddTeamForm'; export default function TeamWebsites({ teamId }) { + const { toast, showToast } = useToast(); const { formatMessage } = useIntl(); const { get, useQuery } = useApi(); - const { data, isLoading } = useQuery(['teams:websites', teamId], () => + const { data, isLoading, refetch } = useQuery(['teams:websites', teamId], () => get(`/teams/${teamId}/websites`), ); const hasData = data && data.length !== 0; @@ -16,10 +29,37 @@ export default function TeamWebsites({ teamId }) { return ; } + const handleSave = async () => { + await refetch(); + showToast({ message: formatMessage(messages.saved), variant: 'success' }); + }; + + const addButton = ( + + + + {close => } + + + ); + return (
- {hasData && } - {!hasData && formatMessage(messages.noData)} + {toast} + {hasData && ( + {addButton} + )} + {hasData && } + {!hasData && ( + + {addButton} + + )}
); } diff --git a/components/pages/settings/teams/TeamWebsitesTable.js b/components/pages/settings/teams/TeamWebsitesTable.js new file mode 100644 index 00000000..a596204c --- /dev/null +++ b/components/pages/settings/teams/TeamWebsitesTable.js @@ -0,0 +1,103 @@ +import Link from 'next/link'; +import { + Table, + TableHeader, + TableBody, + TableRow, + TableCell, + TableColumn, + Button, + Text, + Icon, + Icons, + Flexbox, +} from 'react-basics'; +import { useIntl } from 'react-intl'; +import { labels } from 'components/messages'; +import useUser from 'hooks/useUser'; +import useApi from 'hooks/useApi'; + +export default function TeamWebsitesTable({ teamId, data = [], onSave }) { + const { formatMessage } = useIntl(); + const { user } = useUser(); + const { del, useMutation } = useApi(); + const { mutate } = useMutation(data => del(`/teamWebsites/${data.teamWebsiteId}`)); + + const columns = [ + { name: 'name', label: formatMessage(labels.name), style: { flex: 2 } }, + { name: 'domain', label: formatMessage(labels.domain) }, + { name: 'action', label: ' ' }, + ]; + + const handleRemoveWebsite = teamWebsiteId => { + mutate( + { teamWebsiteId }, + { + onSuccess: async () => { + onSave(); + }, + }, + ); + }; + + return ( + + + {(column, index) => { + return ( + + {column.label} + + ); + }} + + + {(row, keys, rowIndex) => { + const { id: teamWebsiteId } = row; + const { id: websiteId, name, domain, userId } = row.website; + const { teamUser } = row.team; + const owner = teamUser[0]; + const canRemove = user.id === userId || user.id === owner.userId; + + row.name = name; + row.domain = domain; + + row.action = ( + + + + + {canRemove && ( + + )} + + ); + + return ( + + {(data, key, colIndex) => { + return ( + + + {data[key]} + + + ); + }} + + ); + }} + +
+ ); +} diff --git a/components/pages/settings/teams/TeamsList.js b/components/pages/settings/teams/TeamsList.js index b48e9971..0e6ef9c2 100644 --- a/components/pages/settings/teams/TeamsList.js +++ b/components/pages/settings/teams/TeamsList.js @@ -76,7 +76,10 @@ export default function TeamsList() { {hasData && } {!hasData && ( - {createButton} + + {joinButton} + {createButton} + )} diff --git a/components/pages/settings/teams/TeamsTable.js b/components/pages/settings/teams/TeamsTable.js index 7f22435c..4f70cf6d 100644 --- a/components/pages/settings/teams/TeamsTable.js +++ b/components/pages/settings/teams/TeamsTable.js @@ -1,26 +1,28 @@ +import { labels } from 'components/messages'; +import useUser from 'hooks/useUser'; +import { ROLES } from 'lib/constants'; import Link from 'next/link'; import { + Button, + Flexbox, + Icon, + Icons, + Modal, + ModalTrigger, Table, - TableHeader, TableBody, - TableRow, TableCell, TableColumn, - Button, - Icon, - Flexbox, - Icons, + TableHeader, + TableRow, Text, - ModalTrigger, - Modal, } from 'react-basics'; import { useIntl } from 'react-intl'; -import { labels } from 'components/messages'; -import { ROLES } from 'lib/constants'; import TeamDeleteForm from './TeamDeleteForm'; export default function TeamsTable({ data = [], onDelete }) { const { formatMessage } = useIntl(); + const { user } = useUser(); const columns = [ { name: 'name', label: formatMessage(labels.name), style: { flex: 2 } }, @@ -42,10 +44,12 @@ export default function TeamsTable({ data = [], onDelete }) { {(row, keys, rowIndex) => { const { id } = row; + const owner = row.teamUser.find(({ role }) => role === ROLES.teamOwner); + const showDelete = user.id === owner?.userId; const rowData = { ...row, - owner: row.teamUser.find(({ role }) => role === ROLES.teamOwner)?.user?.username, + owner: owner?.user?.username, action: ( @@ -56,24 +60,26 @@ export default function TeamsTable({ data = [], onDelete }) { {formatMessage(labels.edit)} - - - - {close => ( - - )} - - + {showDelete && ( + + + + {close => ( + + )} + + + )} ), }; diff --git a/components/pages/settings/teams/WebsiteAddTeamForm.js b/components/pages/settings/teams/WebsiteAddTeamForm.js new file mode 100644 index 00000000..5d85e5de --- /dev/null +++ b/components/pages/settings/teams/WebsiteAddTeamForm.js @@ -0,0 +1,62 @@ +import { labels } from 'components/messages'; +import useApi from 'hooks/useApi'; +import { useRef, useState } from 'react'; +import { Button, Dropdown, Form, FormButtons, FormRow, Item, SubmitButton } from 'react-basics'; +import { useIntl } from 'react-intl'; +import WebsiteTags from './WebsiteTags'; + +export default function WebsiteAddTeamForm({ teamId, onSave, onClose }) { + const { formatMessage } = useIntl(); + const { get, post, useQuery, useMutation } = useApi(); + const { mutate, error } = useMutation(data => post(`/teams/${teamId}/websites`, data)); + const { data: websites } = useQuery(['websites'], () => get('/websites')); + const [newWebsites, setNewWebsites] = useState([]); + const formRef = useRef(); + + const handleSubmit = () => { + mutate( + { websiteIds: newWebsites }, + { + onSuccess: async () => { + onSave(); + onClose(); + }, + }, + ); + }; + + const handleAddWebsite = value => { + if (!newWebsites.some(a => a === value)) { + const nextValue = [...newWebsites]; + + nextValue.push(value); + + setNewWebsites(nextValue); + } + }; + + const handleRemoveWebsite = value => { + const newValue = newWebsites.filter(a => a !== value); + + setNewWebsites(newValue); + }; + + return ( + <> +
+ + + {({ id, name }) => {name}} + + + + + + {formatMessage(labels.addWebsites)} + + + + + + ); +} diff --git a/components/pages/settings/teams/WebsiteTags.js b/components/pages/settings/teams/WebsiteTags.js new file mode 100644 index 00000000..19179422 --- /dev/null +++ b/components/pages/settings/teams/WebsiteTags.js @@ -0,0 +1,29 @@ +import { Button, Icon, Icons, Text } from 'react-basics'; +import styles from './WebsiteTags.module.css'; + +export default function WebsiteTags({ items = [], websites = [], onClick }) { + if (websites.length === 0) { + return null; + } + + return ( +
+ {websites.map(websiteId => { + const website = items.find(a => a.id === websiteId); + + return ( +
+ +
+ ); + })} +
+ ); +} diff --git a/components/pages/settings/teams/WebsiteTags.module.css b/components/pages/settings/teams/WebsiteTags.module.css new file mode 100644 index 00000000..50ae60a0 --- /dev/null +++ b/components/pages/settings/teams/WebsiteTags.module.css @@ -0,0 +1,11 @@ +.filters { + display: flex; + justify-content: flex-start; + align-items: flex-start; +} + +.tag { + text-align: center; + margin-bottom: 10px; + margin-right: 20px; +} diff --git a/db/postgresql/migrations/07_remove_user_id/migration.sql b/db/postgresql/migrations/07_remove_user_id/migration.sql new file mode 100644 index 00000000..63122f49 --- /dev/null +++ b/db/postgresql/migrations/07_remove_user_id/migration.sql @@ -0,0 +1,19 @@ +/* + Warnings: + + - You are about to drop the column `user_id` on the `team` table. All the data in the column will be lost. + - You are about to drop the column `user_id` on the `team_website` table. All the data in the column will be lost. + +*/ +-- DropIndex +DROP INDEX "team_user_id_idx"; + +-- DropIndex +DROP INDEX "team_website_user_id_idx"; + +-- AlterTable +ALTER TABLE "team" DROP COLUMN "user_id"; + +-- AlterTable +ALTER TABLE "team_website" DROP COLUMN "user_id", +ADD COLUMN "userId" UUID; diff --git a/db/postgresql/schema.prisma b/db/postgresql/schema.prisma index c91d2a06..be3be8f1 100644 --- a/db/postgresql/schema.prisma +++ b/db/postgresql/schema.prisma @@ -17,9 +17,8 @@ model User { updatedAt DateTime? @map("updated_at") @db.Timestamptz(6) deletedAt DateTime? @map("deleted_at") @db.Timestamptz(6) - Website Website[] - teamUser TeamUser[] - teamWebsite TeamWebsite[] + Website Website[] + teamUser TeamUser[] @@map("user") } @@ -86,7 +85,6 @@ model WebsiteEvent { model Team { id String @id() @unique() @map("team_id") @db.Uuid name String @db.VarChar(50) - userId String @map("user_id") @db.Uuid accessCode String? @unique @map("access_code") @db.VarChar(50) createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6) updatedAt DateTime? @map("updated_at") @db.Timestamptz(6) @@ -94,7 +92,6 @@ model Team { teamUser TeamUser[] teamWebsite TeamWebsite[] - @@index([userId]) @@index([accessCode]) @@map("team") } @@ -118,16 +115,13 @@ model TeamUser { model TeamWebsite { id String @id() @unique() @map("team_website_id") @db.Uuid teamId String @map("team_id") @db.Uuid - userId String @map("user_id") @db.Uuid websiteId String @map("website_id") @db.Uuid createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6) team Team @relation(fields: [teamId], references: [id]) - user User @relation(fields: [userId], references: [id]) website Website @relation(fields: [websiteId], references: [id]) @@index([teamId]) - @@index([userId]) @@index([websiteId]) @@map("team_website") } diff --git a/lib/auth.ts b/lib/auth.ts index 1722b15d..b602c3d2 100644 --- a/lib/auth.ts +++ b/lib/auth.ts @@ -1,10 +1,11 @@ import debug from 'debug'; -import { validate } from 'uuid'; import cache from 'lib/cache'; import { PERMISSIONS, ROLE_PERMISSIONS, SHARE_TOKEN_HEADER } from 'lib/constants'; import { secret } from 'lib/crypto'; import { ensureArray, parseSecureToken, parseToken } from 'next-basics'; import { getTeamUser } from 'queries'; +import { getTeamWebsite, getTeamWebsiteByTeamMemberId } from 'queries/admin/teamWebsite'; +import { validate } from 'uuid'; import { Auth } from './types'; const log = debug('umami:auth'); @@ -59,6 +60,12 @@ export async function canViewWebsite({ user, shareToken }: Auth, websiteId: stri return true; } + const teamWebsite = await getTeamWebsiteByTeamMemberId(websiteId, user.id); + + if (teamWebsite) { + return true; + } + const website = await cache.fetchWebsite(websiteId); if (website.userId) { @@ -160,6 +167,26 @@ export async function canDeleteTeam({ user }: Auth, teamId: string) { return false; } +export async function canDeleteTeamWebsite({ user }: Auth, teamWebsiteId: string) { + if (user.isAdmin) { + return true; + } + + if (validate(teamWebsiteId)) { + const teamWebsite = await getTeamWebsite(teamWebsiteId); + + if (teamWebsite.website.userId === user.id) { + return true; + } + + const teamUser = await getTeamUser(teamWebsite.teamId, user.id); + + return hasPermission(teamUser.role, PERMISSIONS.teamDelete); + } + + return false; +} + export async function canCreateUser({ user }: Auth) { return user.isAdmin; } diff --git a/pages/api/teamWebsites/[id].ts b/pages/api/teamWebsites/[id].ts new file mode 100644 index 00000000..96222e08 --- /dev/null +++ b/pages/api/teamWebsites/[id].ts @@ -0,0 +1,31 @@ +import { canDeleteTeamWebsite } from 'lib/auth'; +import { useAuth } from 'lib/middleware'; +import { NextApiRequestQueryBody } from 'lib/types'; +import { NextApiResponse } from 'next'; +import { methodNotAllowed, ok, unauthorized } from 'next-basics'; +import { deleteTeamWebsite } from 'queries/admin/teamWebsite'; + +export interface TeamWebsiteRequestQuery { + id: string; +} + +export default async ( + req: NextApiRequestQueryBody, + res: NextApiResponse, +) => { + await useAuth(req, res); + + const { id: teamWebsiteId } = req.query; + + if (req.method === 'DELETE') { + if (!(await canDeleteTeamWebsite(req.auth, teamWebsiteId))) { + return unauthorized(res); + } + + const websites = await deleteTeamWebsite(teamWebsiteId); + + return ok(res, websites); + } + + return methodNotAllowed(res); +}; diff --git a/pages/api/teams/[id]/websites.ts b/pages/api/teams/[id]/websites.ts index 2c35600d..0db429d1 100644 --- a/pages/api/teams/[id]/websites.ts +++ b/pages/api/teams/[id]/websites.ts @@ -1,17 +1,17 @@ -import { NextApiResponse } from 'next'; -import { methodNotAllowed, ok, unauthorized } from 'next-basics'; -import { NextApiRequestQueryBody } from 'lib/types'; import { canViewTeam } from 'lib/auth'; import { useAuth } from 'lib/middleware'; -import { getTeamWebsites } from 'queries/admin/team'; +import { NextApiRequestQueryBody } from 'lib/types'; +import { NextApiResponse } from 'next'; +import { methodNotAllowed, ok, unauthorized } from 'next-basics'; +import { createTeamWebsites, getTeamWebsites } from 'queries/admin/teamWebsite'; export interface TeamWebsiteRequestQuery { id: string; } export interface TeamWebsiteRequestBody { - websiteId: string; teamWebsiteId?: string; + websiteIds?: string[]; } export default async ( @@ -21,6 +21,9 @@ export default async ( await useAuth(req, res); const { id: teamId } = req.query; + const { + user: { id: userId }, + } = req.auth; if (req.method === 'GET') { if (!(await canViewTeam(req.auth, teamId))) { @@ -32,5 +35,17 @@ export default async ( return ok(res, websites); } + if (req.method === 'POST') { + if (!(await canViewTeam(req.auth, teamId))) { + return unauthorized(res); + } + + const { websiteIds } = req.body; + + const websites = await createTeamWebsites(teamId, websiteIds); + + return ok(res, websites); + } + return methodNotAllowed(res); }; diff --git a/pages/api/teams/index.ts b/pages/api/teams/index.ts index 283f27ca..453f1ef3 100644 --- a/pages/api/teams/index.ts +++ b/pages/api/teams/index.ts @@ -34,12 +34,14 @@ export default async ( const { name } = req.body; - const team = await createTeam({ - id: uuid(), - name, + const team = await createTeam( + { + id: uuid(), + name, + accessCode: getRandomChars(16), + }, userId, - accessCode: getRandomChars(16), - }); + ); return ok(res, team); } diff --git a/pages/api/teams/join.ts b/pages/api/teams/join.ts index 99fa2a5c..95a41423 100644 --- a/pages/api/teams/join.ts +++ b/pages/api/teams/join.ts @@ -6,13 +6,13 @@ import { methodNotAllowed, ok, notFound } from 'next-basics'; import { createTeamUser, getTeam } from 'queries'; import { ROLES } from 'lib/constants'; -export interface TeamsRequestBody { +export interface TeamsJoinRequestBody { accessCode: string; } export default async ( - req: NextApiRequestQueryBody, - res: NextApiResponse, + req: NextApiRequestQueryBody, + res: NextApiResponse, ) => { await useAuth(req, res); diff --git a/queries/admin/team.ts b/queries/admin/team.ts index 19373eba..5f6e763e 100644 --- a/queries/admin/team.ts +++ b/queries/admin/team.ts @@ -18,26 +18,8 @@ export async function getTeams(where: Prisma.TeamWhereInput): Promise { }); } -export async function getTeamWebsites(teamId: string): Promise { - return prisma.client.teamWebsite.findMany({ - where: { - teamId, - }, - include: { - team: true, - }, - orderBy: [ - { - team: { - name: 'asc', - }, - }, - ], - } as any); -} - -export async function createTeam(data: Prisma.TeamCreateInput): Promise { - const { id, userId } = data; +export async function createTeam(data: Prisma.TeamCreateInput, userId: string): Promise { + const { id } = data; return prisma.transaction([ prisma.client.team.create({ diff --git a/queries/admin/teamWebsite.ts b/queries/admin/teamWebsite.ts index affba285..bff1a9ee 100644 --- a/queries/admin/teamWebsite.ts +++ b/queries/admin/teamWebsite.ts @@ -1,43 +1,110 @@ -import { TeamWebsite } from '@prisma/client'; +import { TeamWebsite, Prisma, Website, Team, User } from '@prisma/client'; +import { ROLES } from 'lib/constants'; import { uuid } from 'lib/crypto'; import prisma from 'lib/prisma'; -export async function getTeamWebsite(teamId: string, userId: string): Promise { +export async function getTeamWebsite(teamWebsiteId: string): Promise< + TeamWebsite & { + website: Website; + } +> { return prisma.client.teamWebsite.findFirst({ where: { - teamId, - userId, - }, - }); -} - -export async function getTeamWebsites(teamId: string): Promise { - return prisma.client.teamWebsite.findMany({ - where: { - teamId, + id: teamWebsiteId, }, include: { - user: true, website: true, }, }); } -export async function createTeamWebsite( - userId: string, - teamId: string, +export async function getTeamWebsiteByTeamMemberId( websiteId: string, + userId: string, ): Promise { + return prisma.client.teamWebsite.findFirst({ + where: { + websiteId, + team: { + teamUser: { + some: { + userId, + }, + }, + }, + }, + }); +} + +export async function getTeamWebsites(teamId: string): Promise< + (TeamWebsite & { + team: Team; + website: Website & { + user: User; + }; + })[] +> { + return prisma.client.teamWebsite.findMany({ + where: { + teamId, + }, + include: { + team: { + include: { + teamUser: { + where: { + role: ROLES.teamOwner, + }, + }, + }, + }, + website: { + include: { + user: true, + }, + }, + }, + orderBy: [ + { + team: { + name: 'asc', + }, + }, + ], + }); +} + +export async function createTeamWebsite(teamId: string, websiteId: string): Promise { return prisma.client.teamWebsite.create({ data: { id: uuid(), - userId, teamId, websiteId, }, }); } +export async function createTeamWebsites(teamId: string, websiteIds: string[]) { + const currentTeamWebsites = await getTeamWebsites(teamId); + + // filter out websites that already exists on the team + const addWebsites = websiteIds.filter( + websiteId => !currentTeamWebsites.some(a => a.websiteId === websiteId), + ); + + const teamWebsites: Prisma.TeamWebsiteCreateManyInput[] = addWebsites.map(a => { + return { + id: uuid(), + teamId, + websiteId: a, + }; + }); + + return prisma.client.teamWebsite.createMany({ + data: teamWebsites, + }); +} + export async function deleteTeamWebsite(teamWebsiteId: string): Promise { return prisma.client.teamWebsite.delete({ where: {