diff --git a/src/app/(main)/boards/BoardAddButton.tsx b/src/app/(main)/boards/BoardAddButton.tsx index f9f80f4d..dd25dbf3 100644 --- a/src/app/(main)/boards/BoardAddButton.tsx +++ b/src/app/(main)/boards/BoardAddButton.tsx @@ -16,7 +16,7 @@ export function BoardAddButton() { return ( - + + + ); +} diff --git a/src/app/(main)/boards/[boardId]/BoardPage.tsx b/src/app/(main)/boards/[boardId]/BoardPage.tsx new file mode 100644 index 00000000..ad6a9d30 --- /dev/null +++ b/src/app/(main)/boards/[boardId]/BoardPage.tsx @@ -0,0 +1,17 @@ +'use client'; +import { Column } from '@umami/react-zen'; +import { BoardHeader } from '@/app/(main)/boards/[boardId]/BoardHeader'; +import { PageBody } from '@/components/common/PageBody'; +import { useMessages } from '@/components/hooks'; + +export function BoardPage() { + const { formatMessage, labels } = useMessages(); + + return ( + + + + + + ); +} diff --git a/src/app/(main)/boards/[boardId]/page.tsx b/src/app/(main)/boards/[boardId]/page.tsx index 2cb076a5..82300eb6 100644 --- a/src/app/(main)/boards/[boardId]/page.tsx +++ b/src/app/(main)/boards/[boardId]/page.tsx @@ -1,10 +1,10 @@ import type { Metadata } from 'next'; -import { Board } from './Board'; +import { BoardPage } from './BoardPage'; export default async function ({ params }: { params: Promise<{ boardId: string }> }) { const { boardId } = await params; - return ; + return ; } export const metadata: Metadata = { diff --git a/src/app/api/boards/[boardId]/route.ts b/src/app/api/boards/[boardId]/route.ts new file mode 100644 index 00000000..a5dfd2aa --- /dev/null +++ b/src/app/api/boards/[boardId]/route.ts @@ -0,0 +1,78 @@ +import { z } from 'zod'; +import { SHARE_ID_REGEX } from '@/lib/constants'; +import { parseRequest } from '@/lib/request'; +import { badRequest, json, ok, serverError, unauthorized } from '@/lib/response'; +import { canDeleteBoard, canUpdateBoard, canViewBoard } from '@/permissions'; +import { deleteBoard, getBoard, updateBoard } from '@/queries/prisma'; + +export async function GET(request: Request, { params }: { params: Promise<{ boardId: string }> }) { + const { auth, error } = await parseRequest(request); + + if (error) { + return error(); + } + + const { boardId } = await params; + + if (!(await canViewBoard(auth, boardId))) { + return unauthorized(); + } + + const board = await getBoard(boardId); + + return json(board); +} + +export async function POST(request: Request, { params }: { params: Promise<{ boardId: string }> }) { + const schema = z.object({ + name: z.string().optional(), + domain: z.string().optional(), + shareId: z.string().regex(SHARE_ID_REGEX).nullable().optional(), + }); + + const { auth, body, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const { boardId } = await params; + const { name, domain, shareId } = body; + + if (!(await canUpdateBoard(auth, boardId))) { + return unauthorized(); + } + + try { + const board = await updateBoard(boardId, { name, domain, shareId }); + + return Response.json(board); + } catch (e: any) { + if (e.message.toLowerCase().includes('unique constraint') && e.message.includes('slug')) { + return badRequest({ message: 'That slug is already taken.' }); + } + + return serverError(e); + } +} + +export async function DELETE( + request: Request, + { params }: { params: Promise<{ boardId: string }> }, +) { + const { auth, error } = await parseRequest(request); + + if (error) { + return error(); + } + + const { boardId } = await params; + + if (!(await canDeleteBoard(auth, boardId))) { + return unauthorized(); + } + + await deleteBoard(boardId); + + return ok(); +} diff --git a/src/app/api/boards/route.ts b/src/app/api/boards/route.ts new file mode 100644 index 00000000..53c7f25c --- /dev/null +++ b/src/app/api/boards/route.ts @@ -0,0 +1,61 @@ +import { z } from 'zod'; +import { uuid } from '@/lib/crypto'; +import { getQueryFilters, parseRequest } from '@/lib/request'; +import { json, unauthorized } from '@/lib/response'; +import { pagingParams, searchParams } from '@/lib/schema'; +import { canCreateTeamWebsite, canCreateWebsite } from '@/permissions'; +import { createBoard, getUserBoards } from '@/queries/prisma'; + +export async function GET(request: Request) { + const schema = z.object({ + ...pagingParams, + ...searchParams, + }); + + const { auth, query, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const filters = await getQueryFilters(query); + + const boards = await getUserBoards(auth.user.id, filters); + + return json(boards); +} + +export async function POST(request: Request) { + const schema = z.object({ + type: z.string(), + name: z.string().max(100), + description: z.string().max(500).optional(), + slug: z.string().max(100), + userId: z.uuid().nullable().optional(), + teamId: z.uuid().nullable().optional(), + }); + + const { auth, body, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const { teamId } = body; + + if ((teamId && !(await canCreateTeamWebsite(auth, teamId))) || !(await canCreateWebsite(auth))) { + return unauthorized(); + } + + const data = { + ...body, + id: uuid(), + parameters: {}, + slug: uuid(), + userId: !teamId ? auth.user.id : undefined, + }; + + const result = await createBoard(data); + + return json(result); +} diff --git a/src/app/api/teams/[teamId]/boards/route.ts b/src/app/api/teams/[teamId]/boards/route.ts new file mode 100644 index 00000000..daac2040 --- /dev/null +++ b/src/app/api/teams/[teamId]/boards/route.ts @@ -0,0 +1,29 @@ +import { z } from 'zod'; +import { getQueryFilters, parseRequest } from '@/lib/request'; +import { json, unauthorized } from '@/lib/response'; +import { pagingParams, searchParams } from '@/lib/schema'; +import { canViewTeam } from '@/permissions'; +import { getTeamPixels } from '@/queries/prisma'; + +export async function GET(request: Request, { params }: { params: Promise<{ teamId: string }> }) { + const schema = z.object({ + ...pagingParams, + ...searchParams, + }); + const { teamId } = await params; + const { auth, query, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + if (!(await canViewTeam(auth, teamId))) { + return unauthorized(); + } + + const filters = await getQueryFilters(query); + + const websites = await getTeamPixels(teamId, filters); + + return json(websites); +} diff --git a/src/components/common/DataGrid.tsx b/src/components/common/DataGrid.tsx index 7e07b8dc..b98367e2 100644 --- a/src/components/common/DataGrid.tsx +++ b/src/components/common/DataGrid.tsx @@ -76,7 +76,7 @@ export function DataGrid({ )} { + return get(teamId ? `/teams/${teamId}/boards` : '/boards', pageParams); + }, + ...options, + }); +} diff --git a/src/permissions/board.ts b/src/permissions/board.ts new file mode 100644 index 00000000..c425de87 --- /dev/null +++ b/src/permissions/board.ts @@ -0,0 +1,64 @@ +import { hasPermission } from '@/lib/auth'; +import { PERMISSIONS } from '@/lib/constants'; +import type { Auth } from '@/lib/types'; +import { getBoard, getTeamUser } from '@/queries/prisma'; + +export async function canViewBoard({ user }: Auth, boardId: string) { + if (user?.isAdmin) { + return true; + } + + const board = await getBoard(boardId); + + if (board.userId) { + return user.id === board.userId; + } + + if (board.teamId) { + const teamUser = await getTeamUser(board.teamId, user.id); + + return !!teamUser; + } + + return false; +} + +export async function canUpdateBoard({ user }: Auth, boardId: string) { + if (user.isAdmin) { + return true; + } + + const board = await getBoard(boardId); + + if (board.userId) { + return user.id === board.userId; + } + + if (board.teamId) { + const teamUser = await getTeamUser(board.teamId, user.id); + + return teamUser && hasPermission(teamUser.role, PERMISSIONS.websiteUpdate); + } + + return false; +} + +export async function canDeleteBoard({ user }: Auth, boardId: string) { + if (user.isAdmin) { + return true; + } + + const board = await getBoard(boardId); + + if (board.userId) { + return user.id === board.userId; + } + + if (board.teamId) { + const teamUser = await getTeamUser(board.teamId, user.id); + + return teamUser && hasPermission(teamUser.role, PERMISSIONS.websiteDelete); + } + + return false; +} diff --git a/src/permissions/index.ts b/src/permissions/index.ts index a70808e6..12fbca8a 100644 --- a/src/permissions/index.ts +++ b/src/permissions/index.ts @@ -1,3 +1,4 @@ +export * from './board'; export * from './link'; export * from './pixel'; export * from './report'; diff --git a/src/queries/prisma/board.ts b/src/queries/prisma/board.ts new file mode 100644 index 00000000..9f2037f3 --- /dev/null +++ b/src/queries/prisma/board.ts @@ -0,0 +1,61 @@ +import type { Prisma } from '@/generated/prisma/client'; +import prisma from '@/lib/prisma'; +import type { QueryFilters } from '@/lib/types'; + +export async function findBoard(criteria: Prisma.BoardFindUniqueArgs) { + return prisma.client.board.findUnique(criteria); +} + +export async function getBoard(boardId: string) { + return findBoard({ + where: { + id: boardId, + }, + }); +} + +export async function getBoards(criteria: Prisma.BoardFindManyArgs, filters: QueryFilters = {}) { + const { search } = filters; + const { getSearchParameters, pagedQuery } = prisma; + + const where: Prisma.BoardWhereInput = { + ...criteria.where, + ...getSearchParameters(search, [{ name: 'contains' }, { description: 'contains' }]), + }; + + return pagedQuery('board', { ...criteria, where }, filters); +} + +export async function getUserBoards(userId: string, filters?: QueryFilters) { + return getBoards( + { + where: { + userId, + }, + }, + filters, + ); +} + +export async function getTeamBoards(teamId: string, filters?: QueryFilters) { + return getBoards( + { + where: { + teamId, + }, + }, + filters, + ); +} + +export async function createBoard(data: Prisma.BoardUncheckedCreateInput) { + return prisma.client.board.create({ data }); +} + +export async function updateBoard(boardId: string, data: any) { + return prisma.client.board.update({ where: { id: boardId }, data }); +} + +export async function deleteBoard(boardId: string) { + return prisma.client.board.delete({ where: { id: boardId } }); +} diff --git a/src/queries/prisma/index.ts b/src/queries/prisma/index.ts index b9730f51..233cf671 100644 --- a/src/queries/prisma/index.ts +++ b/src/queries/prisma/index.ts @@ -1,3 +1,4 @@ +export * from './board'; export * from './link'; export * from './pixel'; export * from './report';