diff --git a/next.config.js b/next.config.js index dbc464c..b8039e2 100644 --- a/next.config.js +++ b/next.config.js @@ -11,6 +11,16 @@ const nextConfig = { serverActions: true, serverComponentsExternalPackages: ['mjml', 'mjml-react'], }, + + webpack: (config, { isServer }) => { + // to use metascraper in route handlers https://github.com/uhop/node-re2/issues/63#issuecomment-1785743859 + config.module.rules.push({ + test: /\.node$/, + loader: 'node-loader', + }); + + return config; + }, reactStrictMode: false, }; diff --git a/package.json b/package.json index f0b95ee..b066d45 100644 --- a/package.json +++ b/package.json @@ -69,6 +69,7 @@ "next-connect": "^1.0.0-next.3", "next-contentlayer": "^0.3.2", "next-superjson-plugin": "^0.5.8", + "node-loader": "^2.0.0", "nodemailer": "^6.9.1", "openai": "^4.10.0", "plaiceholder": "^2.5.0", diff --git a/src/app/(admin)/admin/[[...nextadmin]]/page.tsx b/src/app/(admin)/admin/[[...nextadmin]]/page.tsx index 2cab65a..d8a60d3 100644 --- a/src/app/(admin)/admin/[[...nextadmin]]/page.tsx +++ b/src/app/(admin)/admin/[[...nextadmin]]/page.tsx @@ -1,4 +1,5 @@ import schema from '@/../prisma/json-schema/json-schema.json'; +import authOptions from '@/app/api/auth/[...nextauth]/options'; import Dashboard, { DashboardProps } from '@/components/admin/Dashboard'; import { linksByDay, @@ -7,7 +8,6 @@ import { newUsersByMonth, } from '@/lib/adminQueries'; import client from '@/lib/db'; -import { authOptions } from '@/pages/api/auth/[...nextauth]'; import { options } from '@/utils/nextadmin'; import { NextAdmin, PageProps } from '@premieroctet/next-admin'; import { getNextAdminProps } from '@premieroctet/next-admin/dist/appRouter'; diff --git a/src/app/(app)/(routes)/teams/[teamSlug]/layout.tsx b/src/app/(app)/(routes)/teams/[teamSlug]/layout.tsx index a766ee8..1934d0d 100644 --- a/src/app/(app)/(routes)/teams/[teamSlug]/layout.tsx +++ b/src/app/(app)/(routes)/teams/[teamSlug]/layout.tsx @@ -1,5 +1,5 @@ +import authOptions from '@/app/api/auth/[...nextauth]/options'; import { getCurrentUser } from '@/lib/sessions'; -import { authOptions } from '@/pages/api/auth/[...nextauth]'; import { redirect } from 'next/navigation'; export default async function Layout({ diff --git a/src/app/(app)/(routes)/teams/[teamSlug]/settings/informations/page.tsx b/src/app/(app)/(routes)/teams/[teamSlug]/settings/informations/page.tsx index aab1195..0857f2f 100644 --- a/src/app/(app)/(routes)/teams/[teamSlug]/settings/informations/page.tsx +++ b/src/app/(app)/(routes)/teams/[teamSlug]/settings/informations/page.tsx @@ -1,14 +1,13 @@ -import TeamInfo from '@/components/teams/form/settings/TeamInfo'; -import React from 'react'; -import { TeamPageProps } from '../../page'; -import { getCurrentUser } from '@/lib/sessions'; -import { authOptions } from '@/pages/api/auth/[...nextauth]'; -import { redirect } from 'next/navigation'; +import authOptions from '@/app/api/auth/[...nextauth]/options'; import SettingsPageLayout from '@/components/teams/form/settings/SettingsPageLayout'; +import TeamInfo from '@/components/teams/form/settings/TeamInfo'; import { routes } from '@/core/constants'; +import { getCurrentUser } from '@/lib/sessions'; +import { checkUserTeamBySlug } from '@/services/database/user'; import { getTeamSettingsPageInfo } from '@/utils/page'; import Link from 'next/link'; -import { checkUserTeamBySlug } from '@/services/database/user'; +import { redirect } from 'next/navigation'; +import { TeamPageProps } from '../../page'; export default async function Page({ params }: TeamPageProps) { const teamSlug = params.teamSlug; diff --git a/src/app/(app)/(routes)/teams/[teamSlug]/settings/integrations/page.tsx b/src/app/(app)/(routes)/teams/[teamSlug]/settings/integrations/page.tsx index 5358921..3b204a3 100644 --- a/src/app/(app)/(routes)/teams/[teamSlug]/settings/integrations/page.tsx +++ b/src/app/(app)/(routes)/teams/[teamSlug]/settings/integrations/page.tsx @@ -1,13 +1,12 @@ -import React from 'react'; -import { TeamPageProps } from '../../page'; -import { getCurrentUser } from '@/lib/sessions'; -import { authOptions } from '@/pages/api/auth/[...nextauth]'; -import { redirect } from 'next/navigation'; -import TeamIntegrations from '@/components/teams/form/settings/TeamIntegrations'; +import authOptions from '@/app/api/auth/[...nextauth]/options'; import SettingsPageLayout from '@/components/teams/form/settings/SettingsPageLayout'; +import TeamIntegrations from '@/components/teams/form/settings/TeamIntegrations'; import { routes } from '@/core/constants'; -import { getTeamSettingsPageInfo } from '@/utils/page'; +import { getCurrentUser } from '@/lib/sessions'; import { checkUserTeamBySlug } from '@/services/database/user'; +import { getTeamSettingsPageInfo } from '@/utils/page'; +import { redirect } from 'next/navigation'; +import { TeamPageProps } from '../../page'; export default async function Page({ params }: TeamPageProps) { const teamSlug = params.teamSlug; diff --git a/src/app/(app)/(routes)/teams/[teamSlug]/settings/members/page.tsx b/src/app/(app)/(routes)/teams/[teamSlug]/settings/members/page.tsx index a698222..6cf59a8 100644 --- a/src/app/(app)/(routes)/teams/[teamSlug]/settings/members/page.tsx +++ b/src/app/(app)/(routes)/teams/[teamSlug]/settings/members/page.tsx @@ -1,16 +1,15 @@ -import React from 'react'; -import { TeamPageProps } from '../../page'; -import { getCurrentUser } from '@/lib/sessions'; -import { authOptions } from '@/pages/api/auth/[...nextauth]'; -import { redirect } from 'next/navigation'; -import TeamUsers from '@/components/teams/form/settings/TeamUsers'; +import authOptions from '@/app/api/auth/[...nextauth]/options'; import SettingsPageLayout from '@/components/teams/form/settings/SettingsPageLayout'; +import TeamUsers from '@/components/teams/form/settings/TeamUsers'; import { routes } from '@/core/constants'; -import { getTeamSettingsPageInfo } from '@/utils/page'; -import { getTeamMembers } from '@/services/database/membership'; +import { getCurrentUser } from '@/lib/sessions'; import { getTeamInvitations } from '@/services/database/invitation'; +import { getTeamMembers } from '@/services/database/membership'; import { getTeamSubscriptions } from '@/services/database/subscription'; import { checkUserTeamBySlug } from '@/services/database/user'; +import { getTeamSettingsPageInfo } from '@/utils/page'; +import { redirect } from 'next/navigation'; +import { TeamPageProps } from '../../page'; export default async function Page({ params }: TeamPageProps) { const teamSlug = params.teamSlug; diff --git a/src/app/(app)/(routes)/teams/[teamSlug]/settings/page.tsx b/src/app/(app)/(routes)/teams/[teamSlug]/settings/page.tsx index e5a2c07..658d098 100644 --- a/src/app/(app)/(routes)/teams/[teamSlug]/settings/page.tsx +++ b/src/app/(app)/(routes)/teams/[teamSlug]/settings/page.tsx @@ -1,9 +1,9 @@ +import authOptions from '@/app/api/auth/[...nextauth]/options'; +import { routes } from '@/core/constants'; import { getCurrentUser } from '@/lib/sessions'; -import { authOptions } from '@/pages/api/auth/[...nextauth]'; +import { checkUserTeamBySlug } from '@/services/database/user'; import { notFound, redirect } from 'next/navigation'; import { TeamPageProps } from '../page'; -import { routes } from '@/core/constants'; -import { checkUserTeamBySlug } from '@/services/database/user'; const TeamSettingsPage = async ({ params }: TeamPageProps) => { const teamSlug = params.teamSlug; diff --git a/src/app/(app)/(routes)/teams/[teamSlug]/settings/templates/page.tsx b/src/app/(app)/(routes)/teams/[teamSlug]/settings/templates/page.tsx index 65c29e8..d15a304 100644 --- a/src/app/(app)/(routes)/teams/[teamSlug]/settings/templates/page.tsx +++ b/src/app/(app)/(routes)/teams/[teamSlug]/settings/templates/page.tsx @@ -1,14 +1,13 @@ -import React from 'react'; -import { TeamPageProps } from '../../page'; -import { getCurrentUser } from '@/lib/sessions'; -import { authOptions } from '@/pages/api/auth/[...nextauth]'; -import { redirect } from 'next/navigation'; -import TeamTemplates from '@/components/teams/form/settings/TeamTemplates'; +import authOptions from '@/app/api/auth/[...nextauth]/options'; import SettingsPageLayout from '@/components/teams/form/settings/SettingsPageLayout'; +import TeamTemplates from '@/components/teams/form/settings/TeamTemplates'; import { routes } from '@/core/constants'; -import { getTeamSettingsPageInfo } from '@/utils/page'; +import { getCurrentUser } from '@/lib/sessions'; import { getTeamDigests } from '@/services/database/digest'; import { checkUserTeamBySlug } from '@/services/database/user'; +import { getTeamSettingsPageInfo } from '@/utils/page'; +import { redirect } from 'next/navigation'; +import { TeamPageProps } from '../../page'; export default async function Page({ params }: TeamPageProps) { const teamSlug = params.teamSlug; diff --git a/src/app/(app)/(routes)/teams/layout.tsx b/src/app/(app)/(routes)/teams/layout.tsx index 679e3a1..20ba6a5 100644 --- a/src/app/(app)/(routes)/teams/layout.tsx +++ b/src/app/(app)/(routes)/teams/layout.tsx @@ -1,5 +1,5 @@ +import authOptions from '@/app/api/auth/[...nextauth]/options'; import { getCurrentUser } from '@/lib/sessions'; -import { authOptions } from '@/pages/api/auth/[...nextauth]'; import { redirect } from 'next/navigation'; export default async function Layout({ diff --git a/src/app/(admin)/api/admin/[[...nextadmin]]/route.ts b/src/app/api/admin/[[...nextadmin]]/route.ts similarity index 92% rename from src/app/(admin)/api/admin/[[...nextadmin]]/route.ts rename to src/app/api/admin/[[...nextadmin]]/route.ts index 13be2d5..6987a5b 100644 --- a/src/app/(admin)/api/admin/[[...nextadmin]]/route.ts +++ b/src/app/api/admin/[[...nextadmin]]/route.ts @@ -1,6 +1,6 @@ import schema from '@/../prisma/json-schema/json-schema.json'; +import authOptions from '@/app/api/auth/[...nextauth]/options'; import client from '@/lib/db'; -import { authOptions } from '@/pages/api/auth/[...nextauth]'; import { options } from '@/utils/nextadmin'; import { createHandler } from '@premieroctet/next-admin/dist/appHandler'; import { getServerSession } from 'next-auth'; diff --git a/src/pages/api/auth/[...nextauth].tsx b/src/app/api/auth/[...nextauth]/options.tsx similarity index 86% rename from src/pages/api/auth/[...nextauth].tsx rename to src/app/api/auth/[...nextauth]/options.tsx index 3f5c07c..b62b82c 100644 --- a/src/pages/api/auth/[...nextauth].tsx +++ b/src/app/api/auth/[...nextauth]/options.tsx @@ -3,10 +3,10 @@ import { EMAIL_SUBJECTS, sendEmail } from '@/emails'; import LoginEmail from '@/emails/templates/LoginEmail'; import prisma from '@/lib/db'; import { PrismaAdapter } from '@next-auth/prisma-adapter'; -import NextAuth, { NextAuthOptions } from 'next-auth'; +import { AuthOptions } from 'next-auth'; import EmailProvider from 'next-auth/providers/email'; -export const authOptions: NextAuthOptions = { +const options: AuthOptions = { adapter: PrismaAdapter(prisma), providers: [ EmailProvider({ @@ -23,12 +23,12 @@ export const authOptions: NextAuthOptions = { session: async ({ session, user }) => { if (user) { session.user.id = user.id; + session.user.email = user.email; if (user.role) { session.user.role = user.role; } } - return session; }, }, @@ -37,4 +37,4 @@ export const authOptions: NextAuthOptions = { }, }; -export default NextAuth(authOptions); +export default options; diff --git a/src/app/api/auth/[...nextauth]/route.ts b/src/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..d4a10df --- /dev/null +++ b/src/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,6 @@ +import NextAuth from 'next-auth'; +import options from './options'; + +const handler = NextAuth(options); + +export { handler as GET, handler as POST }; diff --git a/src/app/api/bookmark/route.ts b/src/app/api/bookmark/route.ts new file mode 100644 index 0000000..cd3ffdc --- /dev/null +++ b/src/app/api/bookmark/route.ts @@ -0,0 +1,55 @@ +import db from '@/lib/db'; +import { saveBookmark } from '@/services/database/bookmark'; +import { HandlerApiError, HandlerApiResponse } from '@/utils/handlerResponse'; + +import { Bookmark } from '@prisma/client'; +import jwt from 'jsonwebtoken'; +import { NextRequest } from 'next/server'; + +export type ApiBookmarkResponseSuccess = Bookmark; + +// export const router = createRouter(); + +// const UNIQUE_TOKEN_PER_INTERVAL = 500; // 500 requests +// const INTERVAL = 60000; // 1 minute +// interface PostRequestBody { +// linkUrl: string; +// } + +// const limiter = rateLimit({ +// uniqueTokenPerInterval: UNIQUE_TOKEN_PER_INTERVAL, +// interval: INTERVAL, +// }); + +export async function POST(req: NextRequest) { + try { + const { JWT_SECRET } = process.env; + const authHeader = req.headers.get('Authorization'); + if (!authHeader || !authHeader.startsWith('Bearer ')) throw new Error(); + if (!JWT_SECRET) return HandlerApiError.unauthorized(); + const token = authHeader.substring(7, authHeader.length); + const decoded = jwt.verify(token, JWT_SECRET); + if (!decoded) return HandlerApiError.unauthorized(); + + const { teamId } = decoded as { teamId: string }; + if (!teamId) return HandlerApiError.internalServerError(); + + const { linkUrl } = (await req.json()) as { linkUrl: string }; + if (!linkUrl) return HandlerApiError.missingParameters(); + + // @todo implement rate limiting + + const team = await db.team.findFirst({ + where: { + id: teamId, + }, + }); + if (!team) throw new Error(); + const bookmark = await saveBookmark(linkUrl, teamId); + return HandlerApiResponse.success(bookmark); + } catch (error) { + // eslint-disable-next-line no-console + console.log(error); + return HandlerApiError.internalServerError(); + } +} diff --git a/src/app/api/tags/route.ts b/src/app/api/tags/route.ts new file mode 100644 index 0000000..ff77413 --- /dev/null +++ b/src/app/api/tags/route.ts @@ -0,0 +1,24 @@ +import { NextRequest } from 'next/server'; + +import db from '@/lib/db'; +import { HandlerApiError, HandlerApiResponse } from '@/utils/handlerResponse'; + +export async function GET(req: NextRequest) { + try { + /** Get all available tags */ + const tags = await db.tag.findMany({ + select: { + id: true, + name: true, + slug: true, + description: true, + }, + }); + + return HandlerApiResponse.success(tags); + } catch (error) { + // eslint-disable-next-line no-console + console.log(error); + return HandlerApiError.internalServerError(); + } +} diff --git a/src/app/api/teams/[teamId]/bookmark/[bookmarkId]/route.ts b/src/app/api/teams/[teamId]/bookmark/[bookmarkId]/route.ts new file mode 100644 index 0000000..c5b9d7d --- /dev/null +++ b/src/app/api/teams/[teamId]/bookmark/[bookmarkId]/route.ts @@ -0,0 +1,48 @@ +import db from '@/lib/db'; +import { checkTeamAppRouter } from '@/lib/middleware'; +import { HandlerApiError, HandlerApiResponse } from '@/utils/handlerResponse'; +import { Bookmark } from '@prisma/client'; +import { createEdgeRouter } from 'next-connect'; +import { NextRequest } from 'next/server'; + +export type ResponseSuccess = Bookmark; + +export interface TeamsRequestContext { + params: { + teamId: string; + bookmarkId?: string; + }; + + // Not the best way to do this but it works for now + // We need the middleware to set these values + // Right now next-connect doesn't support generics to enrich the request (https://github.com/hoangvvo/next-connect/issues/230) + membershipId: string; + teamId: string; + user: { id: string; email: string }; +} + +const router = createEdgeRouter(); + +router.use(checkTeamAppRouter).delete(async (req, event, next) => { + const bookmarkId = event.params.bookmarkId as string; + const teamId = event.params.teamId as string; + try { + const bookmark = await db.bookmark.findFirstOrThrow({ + where: { id: bookmarkId, teamId }, + }); + + const deletedBookmark = await db.bookmark.delete({ + where: { id: bookmark.id }, + }); + + return HandlerApiResponse.success(deletedBookmark); + } catch (error: unknown) { + // eslint-disable-next-line no-console + console.log(error); + return HandlerApiError.internalServerError(); + } +}); + +export async function DELETE(request: NextRequest, ctx: TeamsRequestContext) { + return router.run(request, ctx) as Promise; +} diff --git a/src/app/api/teams/[teamId]/bookmark/route.ts b/src/app/api/teams/[teamId]/bookmark/route.ts new file mode 100644 index 0000000..211ff75 --- /dev/null +++ b/src/app/api/teams/[teamId]/bookmark/route.ts @@ -0,0 +1,59 @@ +import { checkTeamAppRouter } from '@/lib/middleware'; +import messages from '@/messages/en'; +import { saveBookmark } from '@/services/database/bookmark'; +import { HandlerApiError, HandlerApiResponse } from '@/utils/handlerResponse'; +import { Bookmark } from '@prisma/client'; +import * as Sentry from '@sentry/nextjs'; +import { createEdgeRouter } from 'next-connect'; +import type { NextRequest } from 'next/server'; + +export type ApiBookmarkResponseSuccess = Bookmark; + +export interface TeamsRequestContext { + params: { + teamId: string; + }; + + // Not the best way to do this but it works for now + // We need the middleware to set these values + // Right now next-connect doesn't support generics to enrich the request (https://github.com/hoangvvo/next-connect/issues/230) + membershipId: string; + teamId: string; + user: { id: string; email: string }; +} + +const router = createEdgeRouter(); + +router.use(checkTeamAppRouter).post(async (req, event, next) => { + const body = await req.json(); + const linkUrl = body.link as string; + try { + const teamId = event.params.teamId as string; + const membershipId = event.membershipId as string; + + if (!linkUrl) { + return HandlerApiError.badRequest(); + } + + const bookmark = await saveBookmark(linkUrl, teamId, membershipId); + return HandlerApiResponse.success(bookmark); + } catch (error: unknown) { + const error_code = (error as TypeError) + .message as keyof typeof messages.bookmark.create.error; + + Sentry.captureMessage( + `Failed to save bookmark. Cause: ${ + messages.bookmark.create.error[error_code] ?? + (error as TypeError).message + } (${linkUrl})` + ); + + // eslint-disable-next-line no-console + console.log(error); + return HandlerApiError.internalServerError(); + } +}); + +export async function POST(request: NextRequest, ctx: TeamsRequestContext) { + return router.run(request, ctx) as Promise; +} diff --git a/src/app/api/teams/[teamId]/digests/[digestId]/route.ts b/src/app/api/teams/[teamId]/digests/[digestId]/route.ts new file mode 100644 index 0000000..3d46b7a --- /dev/null +++ b/src/app/api/teams/[teamId]/digests/[digestId]/route.ts @@ -0,0 +1,157 @@ +import client from '@/lib/db'; +import { checkDigestAppRouter, checkTeamAppRouter } from '@/lib/middleware'; +import { HandlerApiError, HandlerApiResponse } from '@/utils/handlerResponse'; +import { openAiCompletion } from '@/utils/openai'; +import { Digest } from '@prisma/client'; +import { createEdgeRouter } from 'next-connect'; +import { NextRequest } from 'next/server'; +import urlSlug from 'url-slug'; + +export type ApiDigestResponseSuccess = Digest; + +export interface TeamsDigestsRequestContext { + params: { + teamId: string; + digestId: string; + }; + + // Not the best way to do this but it works for now + // We need the middleware to set these values + // Right now next-connect doesn't support generics to enrich the request (https://github.com/hoangvvo/next-connect/issues/230) + membershipId: string; + teamId: string; + user: { id: string; email: string }; +} + +const router = createEdgeRouter(); + +router + .use(checkTeamAppRouter) + .use(checkDigestAppRouter) + .patch(async (req, event, next) => { + const digestId = event.params.digestId as string; + try { + const body = await req.json(); + let digest = await client.digest.findUnique({ + select: { publishedAt: true, teamId: true }, + where: { id: digestId?.toString() }, + }); + if (body.title !== undefined && !body.title.trim()) { + /* Ensure title is not empty */ + return HandlerApiError.customError('Title cannot be empty', 400); + } + const isFirstPublication = !digest?.publishedAt && !!body.publishedAt; + if (isFirstPublication && process.env.OPENAI_API_KEY) { + const lastDigests = await client.digest.findMany({ + select: { title: true }, + where: { teamId: digest?.teamId }, + take: 5, + orderBy: { publishedAt: 'desc' }, + }); + if (!digest?.teamId) throw new Error('Missing teamId'); + + const lastDigestTitles = [ + body.title, + ...lastDigests?.map((digest) => digest?.title), + ].filter((title) => !!title); + + updateSuggestedDigestTitle(lastDigestTitles.reverse(), digest?.teamId!); + } + + digest = await client.digest.update({ + where: { + id: digestId?.toString(), + }, + data: { + ...body, + // Do not update slug if digest has been published + ...(body.title && + !digest?.publishedAt && { + slug: urlSlug(body.title), + }), + }, + }); + return HandlerApiResponse.success(digest); + } catch (error: unknown) { + // eslint-disable-next-line no-console + console.log(error); + return HandlerApiError.internalServerError(); + } + }) + .delete(async (req, event, next) => { + const digestId = event.params.digestId; + try { + const digest = await client.digest.delete({ + where: { + id: digestId?.toString(), + }, + }); + + if (!digest) { + return HandlerApiError.notFound(); + } + + const wasAPublishedDigest = Boolean(digest?.publishedAt); + if (wasAPublishedDigest) { + await client.team.update({ + where: { id: digest?.teamId }, + data: { + nextSuggestedDigestTitle: null, + }, + }); + } + + return HandlerApiResponse.success(digest); + } catch (error: unknown) { + // eslint-disable-next-line no-console + console.log(error); + return HandlerApiError.internalServerError(); + } + }); + +async function updateSuggestedDigestTitle( + lastDigestTitles: { + title: string; + }[], + teamId: string +) { + if (Boolean(lastDigestTitles?.length)) { + const prompt = ` + Here is a list of document titles sorted from most recent to oldest, separared by ; signs : ${lastDigestTitles.join( + ';' + )} + Just guess the next document title. Don't add any other sentence in your response. If you can't guess a logical title, just write idk. + `; + + try { + const response = await openAiCompletion({ prompt }); + const guessedTitle = response[0]?.message?.content; + const canPredict = guessedTitle !== 'idk'; + if (canPredict) { + await client.team.update({ + where: { id: teamId }, + data: { + nextSuggestedDigestTitle: guessedTitle, + }, + }); + } + } catch (e) { + // eslint-disable-next-line no-console + console.log(e); + } + } +} + +export async function PATCH( + request: NextRequest, + ctx: TeamsDigestsRequestContext +) { + return router.run(request, ctx) as Promise; +} + +export async function DELETE( + request: NextRequest, + ctx: TeamsDigestsRequestContext +) { + return router.run(request, ctx) as Promise; +} diff --git a/src/app/api/teams/[teamId]/digests/route.ts b/src/app/api/teams/[teamId]/digests/route.ts new file mode 100644 index 0000000..ca3c59b --- /dev/null +++ b/src/app/api/teams/[teamId]/digests/route.ts @@ -0,0 +1,74 @@ +import db from '@/lib/db'; +import { checkTeamAppRouter } from '@/lib/middleware'; +import { + createDigest, + createDigestWithTemplate, +} from '@/services/database/digest-block'; +import { HandlerApiError, HandlerApiResponse } from '@/utils/handlerResponse'; +import { Digest } from '@prisma/client'; +import { createEdgeRouter } from 'next-connect'; +import { NextRequest } from 'next/server'; + +export type ResponseSuccess = Digest; +interface PostBody { + title: string; + isTemplate: boolean; + templateId?: string; +} + +export interface TeamsRequestContext { + params: { + teamId: string; + }; + + // Not the best way to do this but it works for now + // We need the middleware to set these values + // Right now next-connect doesn't support generics to enrich the request (https://github.com/hoangvvo/next-connect/issues/230) + membershipId: string; + teamId: string; + user: { id: string; email: string }; +} + +const router = createEdgeRouter(); + +router.use(checkTeamAppRouter).post(async (req, event, next) => { + try { + const body = (await req.json()) as PostBody; + const { title, templateId } = body; + const teamId = event.params.teamId as string; + if (!title) { + return HandlerApiError.badRequest(); + } + + const nameAlreadyUsed = await db.digest.findFirst({ + where: { + title, + teamId, + }, + }); + + if (nameAlreadyUsed) { + return HandlerApiError.customError( + 'This digest name already exists', + 400 + ); + } + + if (templateId) { + const newDigest = await createDigestWithTemplate({ + title, + templateId, + teamId, + }); + + return HandlerApiResponse.created(newDigest); + } else { + const newDigest = await createDigest({ title, teamId }); + return HandlerApiResponse.created(newDigest); + } + } catch (error: unknown) { + // eslint-disable-next-line no-console + console.log(error); + return HandlerApiError.internalServerError(); + } +}); diff --git a/src/app/api/teams/route.ts b/src/app/api/teams/route.ts new file mode 100644 index 0000000..f8ce053 --- /dev/null +++ b/src/app/api/teams/route.ts @@ -0,0 +1,92 @@ +import db from '@/lib/db'; +import { checkAuthAppRouter } from '@/lib/middleware'; +import { HandlerApiError, HandlerApiResponse } from '@/utils/handlerResponse'; +import { Team } from '@prisma/client'; +import { getServerSession } from 'next-auth'; +import { createEdgeRouter } from 'next-connect'; +import type { NextRequest } from 'next/server'; +import urlSlug from 'url-slug'; +import options from '../auth/[...nextauth]/options'; + +export type ApiTeamResponseSuccess = Team; + +const router = createEdgeRouter(); + +const RESTRICTED_TEAM_NAMES = [ + 'create', + 'login', + 'logout', + 'signup', + 'teams', + 'tags', +]; + +router.use(checkAuthAppRouter).post(async (req, event, next) => { + try { + const session = await getServerSession(options); + if (!session) return HandlerApiError.unauthorized(); + const { teamName } = await req.json(); + if (!teamName) return HandlerApiError.badRequest(); + + if ( + RESTRICTED_TEAM_NAMES.includes(teamName) || + teamName.length === 0 || + teamName.length > 30 || + (teamName as string).trim().length === 0 + ) { + return HandlerApiError.customError('Team name is not valid', 400); + } + + const checkTeamSlug = await db.team.findMany({ + select: { id: true }, + where: { + slug: urlSlug(teamName), + }, + }); + + if (checkTeamSlug.length > 0) { + return HandlerApiError.customError('Team name already taken', 400); + } + + const team = await db.team.create({ + data: { + name: teamName, + slug: urlSlug(teamName), + }, + }); + + await db.user.update({ + data: { + defaultTeamId: team.id, + }, + where: { + id: session.user.id, + }, + }); + + await db.membership.create({ + data: { + team: { + connect: { + id: team.id, + }, + }, + role: 'ADMIN', + invitedEmail: session.user!.email!, + user: { + connect: { + email: session.user!.email!, + }, + }, + }, + }); + + return HandlerApiResponse.created(team); + } catch (error) { + return HandlerApiError.internalServerError(); + } +}); + +export async function POST(request: NextRequest, ctx: {}) { + return router.run(request, ctx) as Promise; +} \ No newline at end of file diff --git a/src/app/api/test-webhooks/slack/events/route.ts b/src/app/api/test-webhooks/slack/events/route.ts new file mode 100644 index 0000000..bbb1f1e --- /dev/null +++ b/src/app/api/test-webhooks/slack/events/route.ts @@ -0,0 +1,68 @@ +import db from '@/lib/db'; +import { saveBookmark } from '@/services/database/bookmark'; +import { TBlock, extractLinksFromBlocks } from '@/utils/slack'; + +interface SlackBody { + team_id: string; + type: 'event_callback' | 'url_verification'; + challenge?: string; + event: { + type: 'message'; + subtype?: 'message_changed'; + team: string; + user: string; + channel: string; + blocks: TBlock[]; + }; +} + +export async function POST(request: Request) { + try { + const body = await request.json(); + const headers = request.headers; + + // Challenge + if (body.type === 'url_verification') { + return new Response(JSON.stringify({ challenge: body.challenge })); + } + + if (headers.get('x-slack-retry-reason') as string) { + return new Response(null, { status: 200 }); + } + + const { event } = body; + + if ( + body.type === 'event_callback' && + event.type === 'message' && + event.subtype !== 'message_changed' + ) { + const blocks = event.blocks; + + const team = await db.team.findFirstOrThrow({ + where: { slackTeamId: event.team }, + }); + + const links = blocks ? extractLinksFromBlocks(blocks) : []; + + const bookmarks = await Promise.all( + links.map((url) => + saveBookmark(url, team.id, undefined, { + slackUserId: event.user, + slackChannelId: event.channel, + }) + ) + ); + + return new Response(JSON.stringify({ bookmarks }), { status: 200 }); + } + + return new Response(JSON.stringify({ error: 'no_handler' }), { + status: 200, + }); + } catch (error) { + return new Response(JSON.stringify({ error: 'Internal error' }), { + status: 500, + }); + } +} diff --git a/src/app/api/test-webhooks/slack/shortcuts/route.ts b/src/app/api/test-webhooks/slack/shortcuts/route.ts new file mode 100644 index 0000000..e29ad97 --- /dev/null +++ b/src/app/api/test-webhooks/slack/shortcuts/route.ts @@ -0,0 +1,63 @@ +import db from '@/lib/db'; +import { saveBookmark } from '@/services/database/bookmark'; +import { TBlock, extractLinksFromBlocks } from '@/utils/slack'; + +interface SlackPayload { + type: 'message_action'; + team: { id: string }; + user: { id: string }; + channel: { id: string }; + response_url: string; + message: { + blocks: TBlock[]; + }; +} + +export async function POST(request: Request) { + try { + const body = await request.json(); + const payload = JSON.parse(body.payload) as SlackPayload; + if (!body || !payload) return new Response(null, { status: 200 }); + + if (payload.type === 'message_action') { + const links = payload.message.blocks + ? extractLinksFromBlocks(payload.message.blocks) + : []; + + const team = await db.team.findFirstOrThrow({ + where: { slackTeamId: payload.team.id }, + }); + + const bookmarks = await Promise.all( + links.map((url) => + saveBookmark(url, team.id, undefined, { + slackUserId: payload.user.id, + slackChannelId: payload.channel.id, + }) + ) + ); + + await fetch(payload.response_url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + text: `:pushpin: ${links.join( + ',' + )} has been added to your team feed *${team.name}*`, + response_type: 'ephemeral', + }), + }); + + return new Response(JSON.stringify({ bookmarks }), { status: 200 }); + } + return new Response(JSON.stringify({ error: 'no_handler' }), { + status: 200, + }); + } catch (error) { + return new Response(JSON.stringify({ error: 'Internal error' }), { + status: 500, + }); + } +} diff --git a/src/app/api/user/[userId]/route.ts b/src/app/api/user/[userId]/route.ts new file mode 100644 index 0000000..bf8c04c --- /dev/null +++ b/src/app/api/user/[userId]/route.ts @@ -0,0 +1,60 @@ +import db from '@/lib/db'; +import { HandlerApiError, HandlerApiResponse } from '@/utils/handlerResponse'; +import { getServerSession } from 'next-auth'; +import { NextRequest } from 'next/server'; +import options from '../../auth/[...nextauth]/options'; + +export async function PUT( + req: NextRequest, + { params }: { params: { userId: string } } +) { + const session = await getServerSession(options); + if (!session) return HandlerApiError.unauthorized(); + const userId = params.userId as string; + if (session.user.id !== userId) { + return HandlerApiError.unauthorized(); + } + + const body = await req.json(); + + const updatedUser = await db.user.update({ + where: { + id: userId, + }, + data: body, + }); + + if (!updatedUser) return HandlerApiError.internalServerError(); + + return HandlerApiResponse.created(updatedUser); +} + +export async function DELETE( + req: NextRequest, + { params }: { params: { userId: string } } +) { + const session = await getServerSession(options); + if (!session) return HandlerApiError.unauthorized(); + const userId = params.userId as string; + if (session.user.id !== userId) { + return HandlerApiError.unauthorized(); + } + + const account = await db.user.delete({ + where: { + id: userId, + }, + }); + + await db.team.deleteMany({ + where: { + memberships: { + none: {}, + }, + }, + }); + + if (!account) return HandlerApiError.internalServerError(); + + return HandlerApiResponse.ok(account); +} diff --git a/src/components/bookmark/BookmarkItem.tsx b/src/components/bookmark/BookmarkItem.tsx index b597c35..cbf6832 100644 --- a/src/components/bookmark/BookmarkItem.tsx +++ b/src/components/bookmark/BookmarkItem.tsx @@ -1,26 +1,26 @@ 'use client'; +import { getEnvHost } from '@/lib/server'; import { getRelativeDate } from '@/utils/date'; import { getDomainFromUrl } from '@/utils/url'; import clsx from 'clsx'; import BookmarkImage from './BookmarkImage'; -import { getEnvHost } from '@/lib/server'; +import { ResponseSuccess } from '@/app/api/teams/[teamId]/bookmark/[bookmarkId]/route'; import api from '@/lib/api'; -import { ApiBookmarkResponseSuccess } from '@/pages/api/teams/[teamId]/bookmark'; import { AxiosError, AxiosResponse } from 'axios'; import { useMutation } from 'react-query'; -import message from '../../messages/en'; import useCustomToast from '@/hooks/useCustomToast'; import useTransitionRefresh from '@/hooks/useTransitionRefresh'; -import BookmarkAddButton from './BookmarkAddButton'; +import { TeamBookmarkedLinkItem } from '@/services/database/link'; import Link from 'next/link'; -import { Tooltip } from '../Tooltip'; +import message from '../../messages/en'; import { DeletePopover, MultipleDeletePopover } from '../Popover'; -import { TeamBookmarkedLinkItem } from '@/services/database/link'; +import { Tooltip } from '../Tooltip'; +import BookmarkAddButton from './BookmarkAddButton'; type Props = { teamLink: TeamBookmarkedLinkItem; @@ -73,7 +73,7 @@ export const BookmarkItem = ({ const bookmarksNumber = teamLink.bookmark?.length; const { mutate: deleteBookmark, isLoading: isDeleting } = useMutation< - AxiosResponse, + AxiosResponse, AxiosError, { bookmarkId: string } >( diff --git a/src/components/digests/DigestCreateInput.tsx b/src/components/digests/DigestCreateInput.tsx index 2975d6d..e365822 100644 --- a/src/components/digests/DigestCreateInput.tsx +++ b/src/components/digests/DigestCreateInput.tsx @@ -1,10 +1,10 @@ 'use client'; +import { ResponseSuccess } from '@/app/api/teams/[teamId]/digests/route'; import { COOKIES, routes } from '@/core/constants'; import useCustomToast from '@/hooks/useCustomToast'; import useTransitionRefresh from '@/hooks/useTransitionRefresh'; import api from '@/lib/api'; -import { ApiDigestResponseSuccess } from '@/pages/api/teams/[teamId]/digests'; import { TeamDigestsResult } from '@/services/database/digest'; import { Team } from '@prisma/client'; import { AxiosError, AxiosResponse } from 'axios'; @@ -43,7 +43,7 @@ export const DigestCreateInput = ({ const { handleSubmit, register, watch } = methods; const { mutate: createDigest, isLoading } = useMutation< - AxiosResponse, + AxiosResponse, AxiosError, { title: string; templateId?: string } >( diff --git a/src/components/digests/templates/TemplateEdit.tsx b/src/components/digests/templates/TemplateEdit.tsx index 0c80fbf..1b7dad9 100644 --- a/src/components/digests/templates/TemplateEdit.tsx +++ b/src/components/digests/templates/TemplateEdit.tsx @@ -1,15 +1,18 @@ 'use client'; -import { useRouter } from 'next/navigation'; import { routes } from '@/core/constants'; import useCustomToast from '@/hooks/useCustomToast'; import useTransitionRefresh from '@/hooks/useTransitionRefresh'; import api from '@/lib/api'; +import { useRouter } from 'next/navigation'; +import { ResponseSuccess } from '@/app/api/teams/[teamId]/digests/route'; +import { formatTemplateTitle } from '@/components/digests/templates/TemplateItem'; import useAddAndRemoveBlockOnDigest from '@/hooks/useAddAndRemoveBlockOnDigest'; -import { ApiDigestResponseSuccess } from '@/pages/api/teams/[teamId]/digests'; +import { getDigest } from '@/services/database/digest'; +import { getTeamBySlug } from '@/services/database/team'; import { reorderList } from '@/utils/actionOnList'; -import { TrashIcon, InformationCircleIcon } from '@heroicons/react/24/outline'; +import { InformationCircleIcon, TrashIcon } from '@heroicons/react/24/outline'; import { DigestBlock, DigestBlockType } from '@prisma/client'; import { AxiosError, AxiosResponse } from 'axios'; import clsx from 'clsx'; @@ -27,9 +30,6 @@ import { DeletePopover } from '../../Popover'; import { BlockListDnd } from '../../digests/BlockListDnd'; import SectionContainer from '../../layout/SectionContainer'; import { Breadcrumb } from '../../teams/Breadcrumb'; -import { formatTemplateTitle } from '@/components/digests/templates/TemplateItem'; -import { getTeamBySlug } from '@/services/database/team'; -import { getDigest } from '@/services/database/digest'; type Props = { template: NonNullable>>; @@ -127,7 +127,7 @@ export const TemplateEdit = ({ template, team }: Props) => { ); const { mutate: deleteDigest, isLoading: isDeleting } = useMutation< - AxiosResponse, + AxiosResponse, AxiosError >( 'delete-digest', diff --git a/src/components/digests/templates/TemplateItem.tsx b/src/components/digests/templates/TemplateItem.tsx index ebfaf57..6d48604 100644 --- a/src/components/digests/templates/TemplateItem.tsx +++ b/src/components/digests/templates/TemplateItem.tsx @@ -1,14 +1,14 @@ 'use client'; -import { DeletePopover } from '@/components/Popover'; -import { Digest, Team } from '@prisma/client'; +import { ResponseSuccess } from '@/app/api/teams/[teamId]/digests/route'; import Button from '@/components/Button'; -import { useMutation } from 'react-query'; -import { AxiosError, AxiosResponse } from 'axios'; -import { ApiDigestResponseSuccess } from '@/pages/api/teams/[teamId]/digests'; -import api from '@/lib/api'; +import { DeletePopover } from '@/components/Popover'; import useCustomToast from '@/hooks/useCustomToast'; import useTransitionRefresh from '@/hooks/useTransitionRefresh'; +import api from '@/lib/api'; +import { Digest, Team } from '@prisma/client'; +import { AxiosError, AxiosResponse } from 'axios'; import Link from 'next/link'; +import { useMutation } from 'react-query'; export const formatTemplateTitle = (title: string, teamSlug: string) => { return title.replace(`${teamSlug}-template-`, ''); @@ -18,7 +18,7 @@ const TemplateItem = ({ template, team }: { template: Digest; team: Team }) => { const { successToast, errorToast } = useCustomToast(); const { refresh, isRefreshing } = useTransitionRefresh(); const { mutate: deleteTemplate, isLoading: isDeleting } = useMutation< - AxiosResponse, + AxiosResponse, AxiosError >( 'delete-template', diff --git a/src/components/pages/DigestEditPage.tsx b/src/components/pages/DigestEditPage.tsx index 0103a50..bbeec37 100644 --- a/src/components/pages/DigestEditPage.tsx +++ b/src/components/pages/DigestEditPage.tsx @@ -6,8 +6,8 @@ import useTransitionRefresh from '@/hooks/useTransitionRefresh'; import api from '@/lib/api'; import { useRouter } from 'next/navigation'; +import { ResponseSuccess } from '@/app/api/teams/[teamId]/bookmark/[bookmarkId]/route'; import useAddAndRemoveBlockOnDigest from '@/hooks/useAddAndRemoveBlockOnDigest'; -import { ApiDigestResponseSuccess } from '@/pages/api/teams/[teamId]/digests'; import { getDigest } from '@/services/database/digest'; import { TeamLinksData } from '@/services/database/link'; import { getTeamBySlug } from '@/services/database/team'; @@ -43,6 +43,7 @@ import { Breadcrumb } from '../teams/Breadcrumb'; import DigestEditSendNewsletter from './DigestEditSendNewsletter'; import DigestEditTypefully from './DigestEditTypefully'; import DigestEditVisit from './DigestEditVisit'; +import { ApiDigestResponseSuccess } from '@/pages/api/teams/[teamId]/template'; type Props = { teamLinksData: TeamLinksData; @@ -170,7 +171,7 @@ export const DigestEditPage = ({ ); const { mutate: deleteDigest, isLoading: isDeleting } = useMutation< - AxiosResponse, + AxiosResponse, AxiosError >( 'delete-digest', diff --git a/src/components/teams/form/CreateTeam.tsx b/src/components/teams/form/CreateTeam.tsx index 67afb1a..e96b111 100644 --- a/src/components/teams/form/CreateTeam.tsx +++ b/src/components/teams/form/CreateTeam.tsx @@ -5,7 +5,7 @@ import { routes } from '@/core/constants'; import useCustomToast from '@/hooks/useCustomToast'; import useTransitionRefresh from '@/hooks/useTransitionRefresh'; import api from '@/lib/api'; -import { ApiTeamResponseSuccess } from '@/pages/api/teams'; +import { ApiTeamResponseSuccess } from '@/app/api/teams/route'; import { AxiosError, AxiosResponse } from 'axios'; import { useRouter } from 'next/navigation'; import { useMutation } from 'react-query'; diff --git a/src/lib/middleware.ts b/src/lib/middleware.ts index e7db499..97cbaf7 100644 --- a/src/lib/middleware.ts +++ b/src/lib/middleware.ts @@ -1,11 +1,17 @@ -import { authOptions } from '@/pages/api/auth/[...nextauth]'; +import options, { + default as authOptions, +} from '@/app/api/auth/[...nextauth]/options'; +import { TeamsRequestContext } from '@/app/api/teams/[teamId]/bookmark/route'; +import { TeamsDigestsRequestContext } from '@/app/api/teams/[teamId]/digests/[digestId]/route'; +import { checkDigestAuth } from '@/services/database/digest'; +import { getTeamMembershipById } from '@/services/database/membership'; +import { getTeamById } from '@/services/database/team'; +import { HandlerApiError } from '@/utils/handlerResponse'; import { NextApiResponse } from 'next'; import { getServerSession } from 'next-auth'; import { NextHandler } from 'next-connect'; +import { NextRequest } from 'next/server'; import { AuthApiRequest } from './router'; -import { getTeamById } from '@/services/database/team'; -import { getTeamMembershipById } from '@/services/database/membership'; -import { checkDigestAuth } from '@/services/database/digest'; export const checkProAccount = async ( req: AuthApiRequest, @@ -50,6 +56,32 @@ export const checkTeam = async ( return next(); }; +export const checkTeamAppRouter = async ( + req: NextRequest, + event: TeamsRequestContext, + next: NextHandler +) => { + const session = await getServerSession(options); + // req.query.state is for Slack integration (we cant choose the name of the query param) + // @todo implement slack integration with teamId (req.query.state) + const teamId = event.params.teamId as string; + + if (!session && !teamId) { + return HandlerApiError.unauthorized(); + } + + const membership = await getTeamMembershipById(teamId, session!.user?.id); + + if (!membership) { + return HandlerApiError.unauthorized(); + } + + event.membershipId = membership.id; + event.teamId = membership.teamId; + event.user = { id: session!.user.id, email: session!.user.email! }; + return next(); +}; + export const checkAuth = async ( req: AuthApiRequest, res: NextApiResponse, @@ -66,6 +98,20 @@ export const checkAuth = async ( return next(); }; +export const checkAuthAppRouter = async ( + req: NextRequest, + event: {}, + next: NextHandler +) => { + const session = await getServerSession(options); + + if (!session) return HandlerApiError.unauthorized(); + const { teamName } = await req.json(); + if (!teamName) return HandlerApiError.badRequest(); + + return next(); +}; + export const checkDigest = async ( req: AuthApiRequest, res: NextApiResponse, @@ -86,3 +132,24 @@ export const checkDigest = async ( return next(); }; + +export const checkDigestAppRouter = async ( + req: NextRequest, + event: TeamsDigestsRequestContext, + next: NextHandler +) => { + const teamId = event.params.teamId as string; + const digestId = event.params.digestId as string; + + if (!teamId && !digestId) { + return HandlerApiError.unauthorized(); + } + + const count = await checkDigestAuth(teamId, digestId); + + if (count === 0) { + return HandlerApiError.unauthorized(); + } + + return next(); +}; \ No newline at end of file diff --git a/src/lib/sessions.ts b/src/lib/sessions.ts index adcf947..edfb1bd 100644 --- a/src/lib/sessions.ts +++ b/src/lib/sessions.ts @@ -1,4 +1,4 @@ -import { authOptions } from '@/pages/api/auth/[...nextauth]'; +import authOptions from '@/app/api/auth/[...nextauth]/options'; import { Session } from 'next-auth'; import { getServerSession } from 'next-auth/next'; import { redirect } from 'next/navigation'; diff --git a/src/pages/api/bookmark/index.ts b/src/pages/api/bookmark/index.ts deleted file mode 100644 index 11c7c1b..0000000 --- a/src/pages/api/bookmark/index.ts +++ /dev/null @@ -1,87 +0,0 @@ -import db from '@/lib/db'; -import { AuthApiRequest, errorHandler } from '@/lib/router'; -import { saveBookmark } from '@/services/database/bookmark'; -import { - ApiError, - InternalServerError, - MissingParametersError, - UnauthorizedError, -} from '@/utils/apiError'; -import { Bookmark } from '@prisma/client'; -import * as Sentry from '@sentry/nextjs'; -import jwt from 'jsonwebtoken'; -import { NextApiResponse } from 'next'; -import { createRouter } from 'next-connect'; -import rateLimit from '../../../utils/rateLimit'; - -export type ApiBookmarkResponseSuccess = Bookmark; - -export const router = createRouter(); - -const UNIQUE_TOKEN_PER_INTERVAL = 500; // 500 requests -const INTERVAL = 60000; // 1 minute -interface PostRequestBody { - linkUrl: string; -} - -const limiter = rateLimit({ - uniqueTokenPerInterval: UNIQUE_TOKEN_PER_INTERVAL, - interval: INTERVAL, -}); - -router.post(async (req, res) => { - const { JWT_SECRET } = process.env; - const { authorization: authHeader } = req.headers; - try { - if (!JWT_SECRET) throw new InternalServerError(); - if (authHeader == undefined || !authHeader.startsWith('Bearer ')) - throw new UnauthorizedError(); - - const token = authHeader.substring(7, authHeader.length); - const decoded = jwt.verify(token, JWT_SECRET); - if (!decoded) throw new UnauthorizedError(); - - const { teamId } = decoded as { teamId: string }; - if (!teamId) throw new UnauthorizedError(); - - const { linkUrl } = req.body as PostRequestBody; - if (!linkUrl) throw new MissingParametersError(); - - await limiter.check(res, 30, token); - - const team = await db.team.findFirst({ - where: { - id: teamId, - }, - }); - if (!team) throw new UnauthorizedError(); - const bookmark = await saveBookmark(linkUrl, teamId); - return res.status(201).json(bookmark); - } catch (error: any) { - // Error that can be thrown by frontend or backend code (no status code etc..) - if (error instanceof TypeError) { - return res.status(400).json({ - error: error.message, - }); - } - // Error specific to the API (rate limit exceeded etc..) - else if (error instanceof ApiError) { - Sentry.captureException(error); - - return res.status(error.status).json({ - error, - }); - } else { - // Unexpected error - Sentry.captureException(error); - - return res.status(500).json({ - error: 'Something went wrong', - }); - } - } -}); - -export default router.handler({ - onError: errorHandler, -}); diff --git a/src/pages/api/tags/index.ts b/src/pages/api/tags/index.ts deleted file mode 100644 index 4e5ba7f..0000000 --- a/src/pages/api/tags/index.ts +++ /dev/null @@ -1,28 +0,0 @@ -import db from '@/lib/db'; -import { AuthApiRequest } from '@/lib/router'; -import { NextApiResponse } from 'next'; -import { createRouter } from 'next-connect'; - -export const router = createRouter(); - -router.get(async (req, res) => { - try { - /** Get all available tags */ - const tags = await db.tag.findMany({ - select: { - id: true, - name: true, - slug: true, - description: true, - }, - }); - - return res.status(200).json({ tags }); - } catch (error) { - // eslint-disable-next-line no-console - console.log(error); - return res.status(500).json({ error: 'Something went wrong' }); - } -}); - -export default router.handler(); diff --git a/src/pages/api/teams/[teamId]/bookmark/[bookmarkId]/index.ts b/src/pages/api/teams/[teamId]/bookmark/[bookmarkId]/index.ts deleted file mode 100644 index 6625a3a..0000000 --- a/src/pages/api/teams/[teamId]/bookmark/[bookmarkId]/index.ts +++ /dev/null @@ -1,34 +0,0 @@ -import client from '@/lib/db'; -import { checkTeam } from '@/lib/middleware'; -import { AuthApiRequest, errorHandler } from '@/lib/router'; -import { Bookmark } from '@prisma/client'; -import { NextApiResponse } from 'next'; -import { createRouter } from 'next-connect'; - -export type ApiBookmarkResponseSuccess = Bookmark; - -export const router = createRouter(); - -router.use(checkTeam).delete(async (req, res) => { - const bookmarkId = req.query.bookmarkId as string; - const teamId = req.query.teamId as string; - - const bookmark = await client.bookmark.findFirstOrThrow({ - where: { - id: bookmarkId, - teamId, - }, - }); - - const deletedBookmark = await client.bookmark.delete({ - where: { - id: bookmark.id, - }, - }); - - return res.status(201).json(deletedBookmark); -}); - -export default router.handler({ - onError: errorHandler, -}); diff --git a/src/pages/api/teams/[teamId]/bookmark/index.ts b/src/pages/api/teams/[teamId]/bookmark/index.ts deleted file mode 100644 index 7d15294..0000000 --- a/src/pages/api/teams/[teamId]/bookmark/index.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { checkTeam } from '@/lib/middleware'; -import { AuthApiRequest, errorHandler } from '@/lib/router'; -import { Bookmark } from '@prisma/client'; -import { NextApiResponse } from 'next'; -import { createRouter } from 'next-connect'; -import * as Sentry from '@sentry/nextjs'; -import messages from '@/messages/en'; -import { saveBookmark } from '@/services/database/bookmark'; - -export type ApiBookmarkResponseSuccess = Bookmark; - -export const router = createRouter(); - -router.use(checkTeam).post(async (req, res) => { - const { link: linkUrl } = req.body; - - if (!linkUrl) { - return res.status(400).end(); - } - - try { - const bookmark = await saveBookmark(linkUrl, req.teamId!, req.membershipId); - return res.status(201).json(bookmark); - } catch (error: unknown) { - // eslint-disable-next-line no-console - console.log(error); - const error_code = (error as TypeError) - .message as keyof typeof messages.bookmark.create.error; - - Sentry.captureMessage( - `Failed to save bookmark. Cause: ${ - messages.bookmark.create.error[error_code] ?? - (error as TypeError).message - } (${linkUrl})` - ); - - return res.status(400).json({ - error: - messages.bookmark.create.error[error_code] ?? messages['default_error'], - }); - } -}); - -export default router.handler({ - onError: errorHandler, -}); diff --git a/src/pages/api/teams/[teamId]/digests/[digestId]/index.ts b/src/pages/api/teams/[teamId]/digests/[digestId]/index.ts deleted file mode 100644 index a5c515c..0000000 --- a/src/pages/api/teams/[teamId]/digests/[digestId]/index.ts +++ /dev/null @@ -1,127 +0,0 @@ -import client, { isUniqueConstraintError } from '@/lib/db'; -import { checkDigest, checkTeam } from '@/lib/middleware'; -import { Digest } from '@prisma/client'; -import { AuthApiRequest, errorHandler } from '@/lib/router'; -import { NextApiResponse } from 'next'; -import { createRouter } from 'next-connect'; -import urlSlug from 'url-slug'; -import { openAiCompletion } from '@/utils/openai'; - -export type ApiDigestResponseSuccess = Digest; - -export const router = createRouter(); - -router - .use(checkTeam) - .use(checkDigest) - .patch(async (req, res) => { - const digestId = req.query.digestId as string; - try { - let digest = await client.digest.findUnique({ - select: { publishedAt: true, teamId: true }, - where: { id: digestId?.toString() }, - }); - if (req.body.title !== undefined && !req.body.title.trim()) { - /* Ensure title is not empty */ - return res.status(400).json({ - error: 'Title cannot be empty', - }); - } - const isFirstPublication = !digest?.publishedAt && !!req.body.publishedAt; - - if (isFirstPublication && process.env.OPENAI_API_KEY) { - const lastDigests = await client.digest.findMany({ - select: { title: true }, - where: { teamId: digest?.teamId }, - take: 5, - orderBy: { publishedAt: 'desc' }, - }); - if (!digest?.teamId) throw new Error('Missing teamId'); - - const lastDigestTitles = [ - req.body.title, - ...lastDigests?.map((digest) => digest?.title), - ].filter((title) => !!title); - - updateSuggestedDigestTitle(lastDigestTitles.reverse(), digest?.teamId!); - } - - digest = await client.digest.update({ - where: { - id: digestId?.toString(), - }, - data: { - ...req.body, - // Do not update slug if digest has been published - ...(req.body.title && - !digest?.publishedAt && { - slug: urlSlug(req.body.title), - }), - }, - }); - - return res.status(201).json(digest); - } catch (e) { - return res.status(400).json( - isUniqueConstraintError(e) && { - error: 'This digest name already exists', - } - ); - } - }) - .delete(async (req, res) => { - const digestId = req.query.digestId; - - const digest = await client.digest.delete({ - where: { - id: digestId?.toString(), - }, - }); - const wasAPublishedDigest = Boolean(digest?.publishedAt); - if (wasAPublishedDigest) { - await client.team.update({ - where: { id: digest?.teamId }, - data: { - nextSuggestedDigestTitle: null, - }, - }); - } - return res.status(201).json(digest); - }); - -export default router.handler({ - onError: errorHandler, -}); - -async function updateSuggestedDigestTitle( - lastDigestTitles: { - title: string; - }[], - teamId: string -) { - if (Boolean(lastDigestTitles?.length)) { - const prompt = ` - Here is a list of document titles sorted from most recent to oldest, separared by ; signs : ${lastDigestTitles.join( - ';' - )} - Just guess the next document title. Don't add any other sentence in your response. If you can't guess a logical title, just write idk. - `; - - try { - const response = await openAiCompletion({ prompt }); - const guessedTitle = response[0]?.message?.content; - const canPredict = guessedTitle !== 'idk'; - if (canPredict) { - await client.team.update({ - where: { id: teamId }, - data: { - nextSuggestedDigestTitle: guessedTitle, - }, - }); - } - } catch (e) { - // eslint-disable-next-line no-console - console.log(e); - } - } -} diff --git a/src/pages/api/teams/[teamId]/digests/index.ts b/src/pages/api/teams/[teamId]/digests/index.ts deleted file mode 100644 index c69bc30..0000000 --- a/src/pages/api/teams/[teamId]/digests/index.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { isUniqueConstraintError } from '@/lib/db'; -import { checkTeam } from '@/lib/middleware'; -import { Digest } from '@prisma/client'; -import { AuthApiRequest, errorHandler } from '@/lib/router'; -import { NextApiResponse } from 'next'; -import { createRouter } from 'next-connect'; -import { - createDigest, - createDigestWithTemplate, -} from '@/services/database/digest-block'; - -export type ApiDigestResponseSuccess = Digest; -interface PostBody { - title: string; - isTemplate: boolean; - templateId?: string; -} - -export const router = createRouter(); -router.use(checkTeam).post(async (req, res) => { - try { - const { title, templateId } = req.body as PostBody; - const teamId = req.teamId!; - - if (templateId) { - const newDigest = await createDigestWithTemplate({ - title, - templateId, - teamId, - }); - return res.status(201).json(newDigest); - } else { - const newDigest = await createDigest({ title, teamId }); - return res.status(201).json(newDigest); - } - } catch (e) { - // eslint-disable-next-line no-console - console.log(e); - return res.status(400).json( - isUniqueConstraintError(e) && { - error: 'This digest name already exists', - } - ); - } -}); - -export default router.handler({ - onError: errorHandler, -}); diff --git a/src/pages/api/teams/index.tsx b/src/pages/api/teams/index.tsx deleted file mode 100644 index 097a31d..0000000 --- a/src/pages/api/teams/index.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import client from '@/lib/db'; -import { checkAuth } from '@/lib/middleware'; -import { Team } from '@prisma/client'; -import urlSlug from 'url-slug'; -import { AuthApiRequest, errorHandler } from '@/lib/router'; -import { NextApiResponse } from 'next'; -import { createRouter } from 'next-connect'; -import { isStringEmpty } from '@/utils/string'; - -export type ApiTeamResponseSuccess = Team; - -export const router = createRouter(); - -const RESTRICTED_TEAM_NAMES = [ - 'create', - 'login', - 'logout', - 'signup', - 'teams', - 'tags', -]; - -router.use(checkAuth).post(async (req, res) => { - const { teamName } = req.body; - - if (!teamName) { - return res.status(403).end(); - } - - if (RESTRICTED_TEAM_NAMES.includes(teamName)) { - return res.status(400).json({ - error: 'Team name is restricted', - }); - } - - if (isStringEmpty(teamName)) { - return res.status(400).json({ - error: 'Team name cannot be empty', - }); - } - - const checkTeamSlug = await client.team.findMany({ - select: { id: true }, - where: { - slug: urlSlug(teamName), - }, - }); - - if (checkTeamSlug.length > 0) { - return res.status(400).json({ - error: 'Team name already taken', - }); - } - - const team = await client.team.create({ - data: { - name: teamName, - slug: urlSlug(teamName), - }, - }); - - await client.user.update({ - data: { - defaultTeamId: team.id, - }, - where: { - id: req.user?.id, - }, - }); - - await client.membership.create({ - data: { - team: { - connect: { - id: team.id, - }, - }, - role: 'ADMIN', - invitedEmail: req.user!.email!, - user: { - connect: { - email: req.user!.email!, - }, - }, - }, - }); - - return res.status(201).json(team); -}); - -export default router.handler({ - onError: errorHandler, -}); diff --git a/src/pages/api/user/[userId]/index.ts b/src/pages/api/user/[userId]/index.ts deleted file mode 100644 index eae6081..0000000 --- a/src/pages/api/user/[userId]/index.ts +++ /dev/null @@ -1,53 +0,0 @@ -import db from '@/lib/db'; -import { checkAuth } from '@/lib/middleware'; -import { AuthApiRequest, errorHandler } from '@/lib/router'; -import { NextApiResponse } from 'next'; -import { createRouter } from 'next-connect'; - -export const router = createRouter(); - -router - .use(checkAuth) - .put(async (req, res) => { - const userId = req.query.userId as string; - - if (req.user!.id !== userId) { - return res.status(403).end(); - } - - const updatedUser = await db.user.update({ - where: { - id: userId, - }, - data: req.body, - }); - - return res.status(200).json(updatedUser); - }) - .delete(async (req, res) => { - const userId = req.query.userId as string; - - if (req.user!.id !== userId) { - return res.status(403).end(); - } - - const account = await db.user.delete({ - where: { - id: userId, - }, - }); - - await db.team.deleteMany({ - where: { - memberships: { - none: {}, - }, - }, - }); - - return res.status(201).json(account); - }); - -export default router.handler({ - onError: errorHandler, -}); diff --git a/src/pages/api/user/default-team.ts b/src/pages/api/user/default-team.ts deleted file mode 100644 index 39da48e..0000000 --- a/src/pages/api/user/default-team.ts +++ /dev/null @@ -1,31 +0,0 @@ -import db from '@/lib/db'; -import { AuthApiRequest } from '@/lib/router'; -import { NextApiResponse } from 'next'; -import { createRouter } from 'next-connect'; - -export const router = createRouter(); - -router.get(async (req, res) => { - const sessionToken = req.query.sessionToken as string; - const teamSlug = req.query.teamSlug as string; - if (!teamSlug || !sessionToken) return res.status(403).end(); - - const userSession = await db.session.findUnique({ - select: { - user: { select: { memberships: { select: { team: true } } } }, - }, - where: { - sessionToken, - }, - }); - const user = userSession?.user; - const team = user?.memberships.find( - (membership) => membership.team.slug === teamSlug - )?.team; - - if (!team) return res.status(403).end(); - - return res.status(200).json({ defaultTeamSlug: team.slug }); -}); - -export default router.handler(); diff --git a/src/utils/handlerResponse.ts b/src/utils/handlerResponse.ts new file mode 100644 index 0000000..1fdcc10 --- /dev/null +++ b/src/utils/handlerResponse.ts @@ -0,0 +1,81 @@ +export const ApiErrorMessages = { + INTERNAL_SERVER_ERROR: 'Internal server error', + UNAUTHORIZED: 'Unauthorized', + RATE_LIMIT_EXCEEDED: 'Rate limit exceeded', + MISSING_PARAMETERS: 'Missing parameters', + BAD_REQUEST: 'Bad request', + NOT_FOUND: 'Not found', +} as const; + +type ApiErrorMessagesType = + (typeof ApiErrorMessages)[keyof typeof ApiErrorMessages]; + +export class HandlerApiError { + static json(body: any, status: number = 200): Response { + return Response.json(body, { + status, + headers: { + 'Content-Type': 'application/json', + }, + }); + } + + static customError(message: string, status: number): Response { + return this.json({ error: message }, status); + } + + static error(message: ApiErrorMessagesType, status: number): Response { + return this.json({ error: message }, status); + } + + static unauthorized(): Response { + return this.error(ApiErrorMessages.UNAUTHORIZED, 401); + } + + static missingParameters(): Response { + return this.error(ApiErrorMessages.MISSING_PARAMETERS, 400); + } + + static rateLimitExceeded(): Response { + return this.error(ApiErrorMessages.RATE_LIMIT_EXCEEDED, 429); + } + + static internalServerError(): Response { + return this.error(ApiErrorMessages.INTERNAL_SERVER_ERROR, 500); + } + + static badRequest(): Response { + return this.error(ApiErrorMessages.BAD_REQUEST, 400); + } + + static notFound(): Response { + return this.error(ApiErrorMessages.NOT_FOUND, 404); + } +} + +export class HandlerApiResponse { + static json(body: any, status: number = 200): Response { + return Response.json(body, { + status, + headers: { + 'Content-Type': 'application/json', + }, + }); + } + + static success(body: any, status: number = 200): Response { + return this.json(body, status); + } + + static created(body: any): Response { + return this.success(body, 201); + } + + static noContent(): Response { + return this.success(null, 204); + } + + static ok(body: any): Response { + return this.success(body, 200); + } +} diff --git a/yarn.lock b/yarn.lock index 97e63ce..7559201 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3881,6 +3881,11 @@ big-integer@^1.6.16: resolved "https://registry.yarnpkg.com/big-integer/-/big-integer-1.6.51.tgz#0df92a5d9880560d3ff2d5fd20245c889d130686" integrity sha512-GPEid2Y9QU1Exl1rpO9B2IPJGHPSupF5GnVIP0blYvNOMer2bTvSWs1jGOUg04hTmu67nmLsQ9TBo1puaotBHg== +big.js@^5.2.2: + version "5.2.2" + resolved "https://registry.yarnpkg.com/big.js/-/big.js-5.2.2.tgz#65f0af382f578bcdc742bd9c281e9cb2d7768328" + integrity sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ== + bin-version-check@~5.1.0: version "5.1.0" resolved "https://registry.yarnpkg.com/bin-version-check/-/bin-version-check-5.1.0.tgz#788e80e036a87313f8be7908bc20e5abe43f0837" @@ -5316,6 +5321,11 @@ emoji-regex@^9.2.2: resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-9.2.2.tgz#840c8803b0d8047f4ff0cf963176b32d4ef3ed72" integrity sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg== +emojis-list@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/emojis-list/-/emojis-list-3.0.0.tgz#5570662046ad29e2e916e71aae260abdff4f6a78" + integrity sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q== + encoding@0.1.13, encoding@^0.1.13: version "0.1.13" resolved "https://registry.yarnpkg.com/encoding/-/encoding-0.1.13.tgz#56574afdd791f54a8e9b2785c0582a2d26210fa9" @@ -7945,7 +7955,7 @@ json5@^1.0.2: dependencies: minimist "^1.2.0" -json5@^2.2.3: +json5@^2.1.2, json5@^2.2.3: version "2.2.3" resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.3.tgz#78cd6f1a19bdc12b73db5ad0c61efd66c1e29283" integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg== @@ -8101,6 +8111,15 @@ lines-and-columns@^1.1.6: resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632" integrity sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg== +loader-utils@^2.0.0: + version "2.0.4" + resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-2.0.4.tgz#8b5cb38b5c34a9a018ee1fc0e6a066d1dfcc528c" + integrity sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw== + dependencies: + big.js "^5.2.2" + emojis-list "^3.0.0" + json5 "^2.1.2" + localforage@^1.8.1: version "1.10.0" resolved "https://registry.yarnpkg.com/localforage/-/localforage-1.10.0.tgz#5c465dc5f62b2807c3a84c0c6a1b1b3212781dd4" @@ -9726,6 +9745,13 @@ node-int64@^0.4.0: resolved "https://registry.yarnpkg.com/node-int64/-/node-int64-0.4.0.tgz#87a9065cdb355d3182d8f94ce11188b825c68a3b" integrity sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw== +node-loader@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/node-loader/-/node-loader-2.0.0.tgz#9109a6d828703fd3e0aa03c1baec12a798071562" + integrity sha512-I5VN34NO4/5UYJaUBtkrODPWxbobrE4hgDqPrjB25yPkonFhCmZ146vTH+Zg417E9Iwoh1l/MbRs1apc5J295Q== + dependencies: + loader-utils "^2.0.0" + node-releases@^2.0.13: version "2.0.13" resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.13.tgz#d5ed1627c23e3461e819b02e57b75e4899b1c81d"