- + ← {t("back")}
diff --git a/src/app/[team]/scrim/[scrimId]/layout.tsx b/src/app/[team]/scrim/[scrimId]/layout.tsx index 304475962..b89711136 100644 --- a/src/app/[team]/scrim/[scrimId]/layout.tsx +++ b/src/app/[team]/scrim/[scrimId]/layout.tsx @@ -1,14 +1,14 @@ -import NoAuthCard from "@/components/auth/no-auth"; +import { NoAuthCard } from "@/components/auth/no-auth"; import { SelectedPlayerProvider } from "@/components/map/player-switcher"; import { isAuthedToViewScrim } from "@/lib/auth"; -export default async function ScrimDashboardLayout({ - children, - params, -}: { - children: React.ReactNode; - params: { team: string; scrimId: string }; -}) { +export default async function ScrimDashboardLayout( + props: LayoutProps<"/[team]/scrim/[scrimId]"> +) { + const params = await props.params; + + const { children } = props; + const id = parseInt(params.scrimId); const isAuthed = await isAuthedToViewScrim(id); diff --git a/src/app/[team]/scrim/[scrimId]/loading.tsx b/src/app/[team]/scrim/[scrimId]/loading.tsx index 45d270820..cfd49909e 100644 --- a/src/app/[team]/scrim/[scrimId]/loading.tsx +++ b/src/app/[team]/scrim/[scrimId]/loading.tsx @@ -1,4 +1,4 @@ -import DashboardLayout from "@/components/dashboard-layout"; +import { DashboardLayout } from "@/components/dashboard-layout"; import { Card } from "@/components/ui/card"; import { Skeleton } from "@/components/ui/skeleton"; import { getTranslations } from "next-intl/server"; diff --git a/src/app/[team]/scrim/[scrimId]/map/[mapId]/layout.tsx b/src/app/[team]/scrim/[scrimId]/map/[mapId]/layout.tsx index 0feb58dee..93599c5f2 100644 --- a/src/app/[team]/scrim/[scrimId]/map/[mapId]/layout.tsx +++ b/src/app/[team]/scrim/[scrimId]/map/[mapId]/layout.tsx @@ -1,16 +1,16 @@ -import NoAuthCard from "@/components/auth/no-auth"; -import Footer from "@/components/footer"; -import MobileBanner from "@/components/map/mobile-banner"; +import { NoAuthCard } from "@/components/auth/no-auth"; +import { Footer } from "@/components/footer"; +import { MobileBanner } from "@/components/map/mobile-banner"; import { SelectedPlayerProvider } from "@/components/map/player-switcher"; import { isAuthedToViewMap } from "@/lib/auth"; -export default async function MapDashboardLayout({ - children, - params, -}: { - children: React.ReactNode; - params: { team: string; scrimId: string; mapId: string }; -}) { +export default async function MapDashboardLayout( + props: LayoutProps<"/[team]/scrim/[scrimId]/map/[mapId]"> +) { + const params = await props.params; + + const { children } = props; + const scrimId = parseInt(params.scrimId); const mapId = parseInt(params.mapId); diff --git a/src/app/[team]/scrim/[scrimId]/map/[mapId]/loading.tsx b/src/app/[team]/scrim/[scrimId]/map/[mapId]/loading.tsx index d776dec23..cb85fd430 100644 --- a/src/app/[team]/scrim/[scrimId]/map/[mapId]/loading.tsx +++ b/src/app/[team]/scrim/[scrimId]/map/[mapId]/loading.tsx @@ -5,7 +5,7 @@ import { CardHeader, CardTitle, } from "@/components/ui/card"; -import CardIcon from "@/components/ui/card-icon"; +import { CardIcon } from "@/components/ui/card-icon"; import { Skeleton } from "@/components/ui/skeleton"; import { getTranslations } from "next-intl/server"; diff --git a/src/app/[team]/scrim/[scrimId]/map/[mapId]/page.tsx b/src/app/[team]/scrim/[scrimId]/map/[mapId]/page.tsx index a8d815fb4..78cf1d2ef 100644 --- a/src/app/[team]/scrim/[scrimId]/map/[mapId]/page.tsx +++ b/src/app/[team]/scrim/[scrimId]/map/[mapId]/page.tsx @@ -7,7 +7,8 @@ import { ComparePlayers } from "@/components/map/compare-players"; import { DefaultOverview } from "@/components/map/default-overview"; import { Killfeed } from "@/components/map/killfeed"; import { MapEvents } from "@/components/map/map-events"; -import PlayerSwitcher from "@/components/map/player-switcher"; +import { PlayerSwitcher } from "@/components/map/player-switcher"; +import { Notifications } from "@/components/notifications"; import { ModeToggle } from "@/components/theme-switcher"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { UserNav } from "@/components/user-nav"; @@ -16,17 +17,15 @@ import { getUser } from "@/data/user-dto"; import { auth } from "@/lib/auth"; import prisma from "@/lib/prisma"; import { translateMapName } from "@/lib/utils"; -import { SearchParams } from "@/types/next"; -import { Metadata } from "next"; +import type { PagePropsWithLocale } from "@/types/next"; +import type { Metadata, Route } from "next"; import { getTranslations } from "next-intl/server"; import Link from "next/link"; -type Props = { - params: { team: string; scrimId: string; mapId: string; locale: string }; - searchParams: SearchParams; -}; - -export async function generateMetadata({ params }: Props): Promise- + ← {t("back")}
diff --git a/src/app/[team]/scrim/[scrimId]/map/[mapId]/player/[playerId]/loading.tsx b/src/app/[team]/scrim/[scrimId]/map/[mapId]/player/[playerId]/loading.tsx index ac3402ed3..7aaf6584f 100644 --- a/src/app/[team]/scrim/[scrimId]/map/[mapId]/player/[playerId]/loading.tsx +++ b/src/app/[team]/scrim/[scrimId]/map/[mapId]/player/[playerId]/loading.tsx @@ -5,7 +5,7 @@ import { CardHeader, CardTitle, } from "@/components/ui/card"; -import CardIcon from "@/components/ui/card-icon"; +import { CardIcon } from "@/components/ui/card-icon"; import { Skeleton } from "@/components/ui/skeleton"; import { getTranslations } from "next-intl/server"; @@ -18,7 +18,7 @@ export default async function PlayerDashboardLoading() {
← {t("back")}
diff --git a/src/app/[team]/scrim/[scrimId]/page.tsx b/src/app/[team]/scrim/[scrimId]/page.tsx
index 1a306187a..a6c6eccd6 100644
--- a/src/app/[team]/scrim/[scrimId]/page.tsx
+++ b/src/app/[team]/scrim/[scrimId]/page.tsx
@@ -1,11 +1,7 @@
-import DashboardLayout from "@/components/dashboard-layout";
-import { GuestNav } from "@/components/guest-nav";
-import { LocaleSwitcher } from "@/components/locale-switcher";
+import { DashboardLayout } from "@/components/dashboard-layout";
import { AddMapCard } from "@/components/map/add-map";
-import { MobileNav } from "@/components/mobile-nav";
import { ClientDate } from "@/components/scrim/client-date";
import { ReplayCode } from "@/components/scrim/replay-code";
-import { ModeToggle } from "@/components/theme-switcher";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import {
Card,
@@ -20,26 +16,23 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
-import { UserNav } from "@/components/user-nav";
import { getScrim } from "@/data/scrim-dto";
import { getUser } from "@/data/user-dto";
import { auth } from "@/lib/auth";
import prisma from "@/lib/prisma";
import { getMapNames, toKebabCase } from "@/lib/utils";
-import { SearchParams } from "@/types/next";
+import type { PagePropsWithLocale } from "@/types/next";
import { $Enums } from "@prisma/client";
import { ExclamationTriangleIcon, Pencil2Icon } from "@radix-ui/react-icons";
-import { Metadata } from "next";
+import type { Metadata, Route } from "next";
import { getTranslations } from "next-intl/server";
import Image from "next/image";
import { notFound } from "next/navigation";
-type Props = {
- params: { team: string; scrimId: string; locale: string };
- searchParams: SearchParams;
-};
-
-export async function generateMetadata({ params }: Props): Promise {
+export async function generateMetadata(
+ props: PagePropsWithLocale<"/[team]/scrim/[scrimId]">
+): Promise {
+ const params = await props.params;
const t = await getTranslations({
locale: params.locale,
namespace: "scrimPage.metadata",
@@ -78,7 +71,10 @@ export async function generateMetadata({ params }: Props): Promise {
};
}
-export default async function ScrimDashboardPage({ params }: Props) {
+export default async function ScrimDashboardPage(
+ props: PagePropsWithLocale<"/[team]/scrim/[scrimId]">
+) {
+ const params = await props.params;
const id = parseInt(params.scrimId);
const session = await auth();
const t = await getTranslations("scrimPage");
@@ -122,109 +118,96 @@ export default async function ScrimDashboardPage({ params }: Props) {
const mapNames = await getMapNames();
return (
-
-
-
-
-
-
-
- {session ? (
-
- ) : (
-
- )}
-
+
+
+
+ ← {t("back")}
+
+
+
+
+ {scrim?.name ?? t("newScrim")}{" "}
+ {hasPerms && (
+
+
+
+
+
+
+ {t("edit")}
+
+
+
+ )}
+
+
-
-
- ← {t("back")}
-
-
-
-
- {scrim?.name ?? t("newScrim")}{" "}
- {hasPerms && (
+
+
+
+
+ {t("maps.title")}
+
+ {maps.length > 0 ? (
+
+ {maps.map((map) => (
+
+
-
-
-
-
-
- {t("edit")}
-
-
+
+
+ {mapNames.get(toKebabCase(map.name)) ?? map.name}
+
+
+
+
+
- )}
-
-
+
+
+
+
+
+
+
+ ))}
+ {hasPerms && }
-
-
-
-
- {t("maps.title")}
-
- {maps.length > 0 ? (
-
- {maps.map((map) => (
-
-
-
-
-
- {mapNames.get(toKebabCase(map.name)) ?? map.name}
-
-
-
-
-
-
-
-
-
-
-
-
-
- ))}
- {hasPerms && }
-
- ) : (
- <>
-
-
- {t("noMaps.title")}
-
- {t("noMaps.description")}
-
- {t("noMaps.link")}
-
- .
-
-
- {hasPerms && }
- >
- )}
-
+ ) : (
+ <>
+
+
+ {t("noMaps.title")}
+
+ {t("noMaps.description")}
+
+ {t("noMaps.link")}
+
+ .
+
+
+ {hasPerms && }
+ >
+ )}
);
diff --git a/src/app/about/layout.tsx b/src/app/about/layout.tsx
index 2aed431a4..638ba7677 100644
--- a/src/app/about/layout.tsx
+++ b/src/app/about/layout.tsx
@@ -1,10 +1,6 @@
-import Header from "@/components/header";
+import { Header } from "@/components/header";
-export default function AboutPageLayout({
- children,
-}: {
- children: React.ReactNode;
-}) {
+export default function AboutPageLayout({ children }: LayoutProps<"/about">) {
return (
diff --git a/src/app/about/page.tsx b/src/app/about/page.tsx
index 7b9327659..92d84560f 100644
--- a/src/app/about/page.tsx
+++ b/src/app/about/page.tsx
@@ -1,3 +1,4 @@
+import { Link } from "@/components/ui/link";
import {
AcademicCapIcon,
HandRaisedIcon,
@@ -6,10 +7,10 @@ import {
ServerStackIcon,
UserGroupIcon,
} from "@heroicons/react/20/solid";
+import type { Route } from "next";
import { getTranslations } from "next-intl/server";
import Image from "next/image";
-import Link from "next/link";
-import { SVGProps } from "react";
+import type { SVGProps } from "react";
export default async function AboutPage() {
const t = await getTranslations("aboutPage");
@@ -189,7 +190,7 @@ export default async function AboutPage() {
{/* Header section */}
-
+
{t("headerSection.title")}
@@ -200,7 +201,7 @@ export default async function AboutPage() {
{/* Content section */}
-
+
{t("contentSection.description1")}
{t("contentSection.description2")}
@@ -250,19 +251,19 @@ export default async function AboutPage() {
{/* Values section */}
-
+
{t("featureSection.title")}
{t("featureSection.description")}
-
+
{values.map((value) => (
-
{value.name}
@@ -274,10 +275,10 @@ export default async function AboutPage() {
{/* Team section */}
-
+
-
+
{t("teamSection.title")}
@@ -294,7 +295,7 @@ export default async function AboutPage() {
width={324}
height={300}
/>
-
+
{person.name}
@@ -324,7 +325,7 @@ export default async function AboutPage() {
(
-
+
{t("footerNav.solutions.title")}
{footerNavigation.solutions.map((item) => (
-
-
+
{t("footerNav.support.title")}
{footerNavigation.support.map((item) => (
-
-
+
{t("footerNav.company.title")}
{footerNavigation.company.map((item) => (
-
-
+
{t("footerNav.legal.title")}
{footerNavigation.legal.map((item) => (
-
-
+
© 2024–{new Date().getFullYear()}{" "}
{t("footerNav.copyright")}
diff --git a/src/app/api/(cron)/delete-empty-scrims/route.ts b/src/app/api/(cron)/delete-empty-scrims/route.ts
index 066ab5f77..f50ceb585 100644
--- a/src/app/api/(cron)/delete-empty-scrims/route.ts
+++ b/src/app/api/(cron)/delete-empty-scrims/route.ts
@@ -1,4 +1,4 @@
-import Logger from "@/lib/logger";
+import { Logger } from "@/lib/logger";
import prisma from "@/lib/prisma";
export async function DELETE() {
diff --git a/src/app/api/(cron)/delete-unused-blobs/route.ts b/src/app/api/(cron)/delete-unused-blobs/route.ts
index 445b511f0..12ebf7056 100644
--- a/src/app/api/(cron)/delete-unused-blobs/route.ts
+++ b/src/app/api/(cron)/delete-unused-blobs/route.ts
@@ -1,4 +1,4 @@
-import Logger from "@/lib/logger";
+import { Logger } from "@/lib/logger";
import prisma from "@/lib/prisma";
import { del, list } from "@vercel/blob";
diff --git a/src/app/api/(email)/send-contact-form-email/route.ts b/src/app/api/(email)/send-contact-form-email/route.ts
index 33a2ea7f5..8ef064f41 100644
--- a/src/app/api/(email)/send-contact-form-email/route.ts
+++ b/src/app/api/(email)/send-contact-form-email/route.ts
@@ -1,15 +1,16 @@
import ContactFormEmail from "@/components/email/contact-form";
-import { sendEmail } from "@/lib/email";
-import Logger from "@/lib/logger";
+import { email } from "@/lib/email";
+import { Logger } from "@/lib/logger";
import { render } from "@react-email/render";
import { Ratelimit } from "@upstash/ratelimit";
+import { ipAddress } from "@vercel/functions";
import { kv } from "@vercel/kv";
-import { NextRequest } from "next/server";
+import type { NextRequest } from "next/server";
import { z } from "zod";
const ContactFormEmailSchema = z.object({
name: z.string().min(1),
- email: z.string().email(),
+ email: z.email(),
message: z.string().min(1),
});
@@ -22,7 +23,7 @@ export async function POST(req: NextRequest) {
});
// Limit the requests to 5 per minute per user
- const identifier = req.ip ?? "127.0.0.1";
+ const identifier = ipAddress(req) ?? "127.0.0.1";
const { success } = await ratelimit.limit(identifier);
if (!success) {
@@ -35,7 +36,7 @@ export async function POST(req: NextRequest) {
return new Response("Invalid request", { status: 400 });
}
- const emailHtml = render(
+ const emailHtml = await render(
ContactFormEmail({
name: body.data.name,
email: body.data.email,
@@ -44,7 +45,7 @@ export async function POST(req: NextRequest) {
);
try {
- await sendEmail({
+ await email.sendEmail({
to: "help@parsertime.app",
from: "noreply@lux.dev",
subject: `New message from ${body.data.name} | Parsertime`,
diff --git a/src/app/api/(email)/send-team-invite/route.ts b/src/app/api/(email)/send-team-invite/route.ts
index 9604a9fbe..206003f1f 100644
--- a/src/app/api/(email)/send-team-invite/route.ts
+++ b/src/app/api/(email)/send-team-invite/route.ts
@@ -1,11 +1,15 @@
import TeamInviteUserEmail from "@/components/email/team-invite";
import { getUser } from "@/data/user-dto";
-import { sendEmail } from "@/lib/email";
+import { auditLog } from "@/lib/audit-logs";
+import { email } from "@/lib/email";
import { createShortLink } from "@/lib/link-service";
+import { Logger } from "@/lib/logger";
+import { notifications } from "@/lib/notifications";
import prisma from "@/lib/prisma";
+import { isTaggedError } from "@/lib/utils";
import { render } from "@react-email/render";
import { track } from "@vercel/analytics/server";
-import { NextRequest } from "next/server";
+import { after, type NextRequest } from "next/server";
export async function POST(req: NextRequest) {
const inviteeEmail = req.nextUrl.searchParams.get("email");
@@ -40,7 +44,7 @@ export async function POST(req: NextRequest) {
`${baseUrl}/team/join/${inviteToken}`
);
- const emailHtml = render(
+ const emailHtml = await render(
TeamInviteUserEmail({
username: inviteeEmail,
userImage: user.image ?? `https://avatar.vercel.sh/${user.name}.png`,
@@ -53,22 +57,75 @@ export async function POST(req: NextRequest) {
);
try {
- await sendEmail({
+ await notifications.createInAppNotification({
+ userId: user.id,
+ title: `You've been invited to join ${team.name} on Parsertime`,
+ description: `You've been invited to join ${team.name} by ${inviter.name}. Click this notification to accept the invitation.`,
+ href: `/team/join/${inviteToken}`,
+ });
+ } catch (error) {
+ if (error && typeof error === "object" && "_tag" in error) {
+ Logger.error("Error creating in-app notification", error._tag);
+ }
+ // fail silently, just log the error as notifications are not critical
+ }
+
+ try {
+ await email.sendEmail({
to: inviteeEmail,
from: "noreply@lux.dev",
subject: `Join ${team.name} on Parsertime`,
html: emailHtml,
});
} catch (error) {
- return new Response("Error sending email", { status: 500 });
- }
+ if (isTaggedError(error)) {
+ switch (error._tag) {
+ case "ValidationError":
+ return new Response("Invalid email arguments", { status: 400 });
+ case "ConfigurationError":
+ return new Response(
+ "Configuration error with email service. Please contact support.",
+ { status: 500 }
+ );
+ case "RateLimitError":
+ return new Response(
+ "Rate limit exceeded for sending email. Try again later.",
+ { status: 429 }
+ );
+ case "EmailSendError":
+ return new Response(
+ "Error sending email with email service. Please contact support.",
+ { status: 500 }
+ );
+ default:
+ return new Response(
+ "Unknown error sending email. Please contact support.",
+ { status: 500 }
+ );
+ }
+ }
- await track("Email Sent", { type: "Team Invite" });
+ return new Response(
+ "Unknown error sending email. Please contact support.",
+ { status: 500 }
+ );
+ }
- await track("Team Invite Sent", {
- user: inviter.email,
- team: team.name,
- invitee: inviteeEmail,
+ after(async () => {
+ await Promise.all([
+ auditLog.createAuditLog({
+ userEmail: inviter.email,
+ action: "TEAM_INVITE_SENT",
+ target: inviteeEmail,
+ details: `Invited ${inviteeEmail} to join ${team.name}`,
+ }),
+ track("Team Invite Sent", {
+ user: inviter.email,
+ team: team.name,
+ invitee: inviteeEmail,
+ }),
+ track("Email Sent", { type: "Team Invite" }),
+ ]);
});
return new Response("OK", { status: 200 });
diff --git a/src/app/api/admin/audit-logs/route.ts b/src/app/api/admin/audit-logs/route.ts
new file mode 100644
index 000000000..5b00c0036
--- /dev/null
+++ b/src/app/api/admin/audit-logs/route.ts
@@ -0,0 +1,91 @@
+import { getUser } from "@/data/user-dto";
+import { auth } from "@/lib/auth";
+import prisma from "@/lib/prisma";
+import { $Enums, type AuditLogAction, type Prisma } from "@prisma/client";
+import { forbidden, unauthorized } from "next/navigation";
+import { type NextRequest, NextResponse } from "next/server";
+
+export async function GET(req: NextRequest) {
+ const session = await auth();
+ if (!session) unauthorized();
+
+ const user = await getUser(session.user.email);
+ if (!user) unauthorized();
+ if (user.role !== $Enums.UserRole.ADMIN) forbidden();
+
+ const searchParams = req.nextUrl.searchParams;
+ const cursor = searchParams.get("cursor");
+ const limit = searchParams.get("limit") ?? "10";
+ const parsedLimit = parseInt(limit);
+ const userEmailSearch = searchParams.get("userEmail");
+ const targetSearch = searchParams.get("target");
+
+ const actionParams = searchParams.getAll("action") as AuditLogAction[];
+ const startDateParam = searchParams.get("startDate");
+ const endDateParam = searchParams.get("endDate");
+
+ const whereClause: Prisma.AuditLogWhereInput = {};
+
+ if (actionParams.length > 0) {
+ if (actionParams.length === 1) {
+ whereClause.action = actionParams[0];
+ } else {
+ whereClause.action = { in: actionParams };
+ }
+ }
+
+ if (userEmailSearch) {
+ whereClause.userEmail = {
+ contains: userEmailSearch,
+ mode: "insensitive",
+ };
+ }
+
+ if (targetSearch) {
+ whereClause.target = {
+ contains: targetSearch,
+ mode: "insensitive",
+ };
+ }
+
+ const createdAtFilter: Prisma.DateTimeFilter<"AuditLog"> = {};
+ if (startDateParam) {
+ const startDate = new Date(startDateParam);
+ if (!isNaN(startDate.getTime())) {
+ createdAtFilter.gte = startDate;
+ }
+ }
+ if (endDateParam) {
+ const endDate = new Date(endDateParam);
+ if (!isNaN(endDate.getTime())) {
+ createdAtFilter.lte = endDate;
+ }
+ }
+ if (Object.keys(createdAtFilter).length > 0) {
+ whereClause.createdAt = createdAtFilter;
+ }
+
+ const auditLogs = await prisma.auditLog.findMany({
+ where: whereClause,
+ take: parsedLimit,
+ ...(cursor
+ ? {
+ skip: 1, // Skip the cursor
+ cursor: {
+ id: parseInt(cursor),
+ },
+ }
+ : {}),
+ orderBy: {
+ createdAt: "desc",
+ },
+ });
+
+ const nextCursor = auditLogs[auditLogs.length - 1]?.id;
+
+ return NextResponse.json({
+ items: auditLogs,
+ nextCursor: nextCursor?.toString() ?? undefined,
+ hasMore: auditLogs.length === parsedLimit,
+ });
+}
diff --git a/src/app/api/admin/impersonate-user/route.ts b/src/app/api/admin/impersonate-user/route.ts
index 3ba50453a..6457ca5f2 100644
--- a/src/app/api/admin/impersonate-user/route.ts
+++ b/src/app/api/admin/impersonate-user/route.ts
@@ -1,34 +1,39 @@
import { getUser } from "@/data/user-dto";
+import { auditLog } from "@/lib/audit-logs";
import { auth, getImpersonateUrl } from "@/lib/auth";
import { $Enums } from "@prisma/client";
-import { NextRequest } from "next/server";
+import { unauthorized } from "next/navigation";
+import { after, type NextRequest } from "next/server";
import { z } from "zod";
const ImpersonateUserSchema = z.object({
- email: z.string().email(),
+ email: z.email(),
isProd: z.boolean(),
});
export async function POST(req: NextRequest) {
const session = await auth();
-
- if (!session || !session.user) {
- return new Response("Unauthorized", { status: 401 });
- }
+ if (!session?.user) unauthorized();
const user = await getUser(session.user.email);
- if (!user) return new Response("Unauthorized", { status: 401 });
-
- if (user.role !== $Enums.UserRole.ADMIN) {
- return new Response("Unauthorized", { status: 401 });
- }
+ if (!user) unauthorized();
+ if (user.role !== $Enums.UserRole.ADMIN) unauthorized();
const body = ImpersonateUserSchema.safeParse(await req.json());
if (!body.success) return new Response("Invalid request", { status: 400 });
const url = await getImpersonateUrl(body.data.email, body.data.isProd);
+ after(async () => {
+ await auditLog.createAuditLog({
+ userEmail: session.user.email,
+ action: "IMPERSONATE_USER",
+ target: body.data.email,
+ details: `Impersonated ${body.data.email}`,
+ });
+ });
+
return new Response(JSON.stringify({ url }), {
headers: { "content-type": "application/json" },
});
diff --git a/src/app/api/admin/user-search/route.ts b/src/app/api/admin/user-search/route.ts
new file mode 100644
index 000000000..84b9bc90f
--- /dev/null
+++ b/src/app/api/admin/user-search/route.ts
@@ -0,0 +1,94 @@
+import { getUser } from "@/data/user-dto";
+import { auth } from "@/lib/auth";
+import prisma from "@/lib/prisma";
+import type { Prisma } from "@prisma/client";
+import { $Enums } from "@prisma/client";
+import { forbidden, unauthorized } from "next/navigation";
+import { type NextRequest, NextResponse } from "next/server";
+
+export async function GET(req: NextRequest) {
+ const session = await auth();
+ if (!session) unauthorized();
+
+ const user = await getUser(session.user.email);
+ if (!user) unauthorized();
+ if (user.role !== $Enums.UserRole.ADMIN) forbidden();
+
+ const searchParams = req.nextUrl.searchParams;
+ const cursor = searchParams.get("cursor");
+ const limit = searchParams.get("limit") ?? "10";
+ const parsedLimit = parseInt(limit);
+ const searchQuery = searchParams.get("search");
+ const billingPlan = searchParams.get("billingPlan");
+ const joinedAfter = searchParams.get("joinedAfter");
+ const joinedBefore = searchParams.get("joinedBefore");
+
+ const whereClause: Prisma.UserWhereInput = {
+ AND: [
+ // Search by username or email
+ searchQuery
+ ? {
+ OR: [
+ {
+ name: {
+ contains: searchQuery,
+ mode: "insensitive",
+ },
+ },
+ {
+ email: {
+ contains: searchQuery,
+ mode: "insensitive",
+ },
+ },
+ ],
+ }
+ : {},
+ // Filter by billing plan
+ billingPlan
+ ? {
+ billingPlan: billingPlan as $Enums.BillingPlan,
+ }
+ : {},
+ // Filter by join date range
+ joinedAfter || joinedBefore
+ ? {
+ createdAt: {
+ ...(joinedAfter && { gte: new Date(joinedAfter) }),
+ ...(joinedBefore && { lte: new Date(joinedBefore) }),
+ },
+ }
+ : {},
+ ],
+ };
+
+ const users = await prisma.user.findMany({
+ where: whereClause,
+ select: {
+ id: true,
+ name: true,
+ email: true,
+ billingPlan: true,
+ role: true,
+ emailVerified: true,
+ createdAt: true,
+ },
+ take: parsedLimit,
+ ...(cursor
+ ? {
+ skip: 1, // Skip the cursor
+ cursor: {
+ id: cursor,
+ },
+ }
+ : {}),
+ });
+
+ const nextCursor = users[users.length - 1]?.id;
+
+ return NextResponse.json({
+ items: users,
+ nextCursor: nextCursor ?? null,
+ hasMore: users.length === parsedLimit,
+ });
+}
diff --git a/src/app/api/notifications/mark-read/route.ts b/src/app/api/notifications/mark-read/route.ts
new file mode 100644
index 000000000..9ab405d92
--- /dev/null
+++ b/src/app/api/notifications/mark-read/route.ts
@@ -0,0 +1,62 @@
+import { getUser } from "@/data/user-dto";
+import { auth } from "@/lib/auth";
+import { Logger } from "@/lib/logger";
+import { notifications } from "@/lib/notifications";
+import { notFound, unauthorized } from "next/navigation";
+import type { NextRequest } from "next/server";
+import { z } from "zod";
+
+const bodySchema = z.object({
+ id: z.number().optional(),
+ all: z.boolean().optional(),
+});
+
+export async function POST(request: NextRequest) {
+ const session = await auth();
+ if (!session) unauthorized();
+
+ const user = await getUser(session.user.email);
+ if (!user) unauthorized();
+
+ const body = bodySchema.safeParse(await request.json());
+ if (!body.success) {
+ return new Response("Invalid request body", { status: 400 });
+ }
+ const { id, all } = body.data;
+
+ try {
+ if (all) {
+ await notifications.markAllAsRead(user.id);
+ return new Response("OK", { status: 200 });
+ }
+
+ if (id) {
+ await notifications.markAsRead(user.id, id);
+ return new Response("OK", { status: 200 });
+ }
+
+ return new Response(
+ "An unknown error occurred. Please ensure the request body is valid.",
+ {
+ status: 400,
+ }
+ );
+ } catch (error) {
+ Logger.error("Error marking notification as read:", error);
+
+ // Handle specific error types if needed
+ if (error && typeof error === "object" && "_tag" in error) {
+ if (error._tag === "UnauthorizedError") {
+ unauthorized();
+ }
+ if (error._tag === "NotFoundError") {
+ notFound();
+ }
+ if (error._tag === "DatabaseError") {
+ return new Response("Database error", { status: 500 });
+ }
+ }
+
+ return new Response("Internal server error", { status: 500 });
+ }
+}
diff --git a/src/app/api/notifications/route.ts b/src/app/api/notifications/route.ts
new file mode 100644
index 000000000..876bc0b08
--- /dev/null
+++ b/src/app/api/notifications/route.ts
@@ -0,0 +1,160 @@
+import { getUser } from "@/data/user-dto";
+import { auth } from "@/lib/auth";
+import { Logger } from "@/lib/logger";
+import { notifications } from "@/lib/notifications";
+import { getSession } from "next-auth/react";
+import { notFound, unauthorized } from "next/navigation";
+import type { NextRequest } from "next/server";
+import { z } from "zod";
+
+const createNotificationSchema = z.object({
+ title: z.string().min(1),
+ description: z.string().min(1),
+ href: z.string().optional(),
+ userId: z.string().optional(),
+});
+
+export async function GET(request: NextRequest) {
+ const session = await auth();
+ if (!session) unauthorized();
+
+ const user = await getUser(session.user.email);
+ if (!user) unauthorized();
+
+ const searchParams = request.nextUrl.searchParams;
+ const page = parseInt(searchParams.get("page") ?? "1");
+ const limit = parseInt(searchParams.get("limit") ?? "10");
+
+ try {
+ const result = await notifications.getUserNotificationsPaginated(
+ user.id,
+ page,
+ limit
+ );
+
+ return new Response(
+ JSON.stringify({
+ notifications: result.notifications,
+ pagination: result.pagination,
+ })
+ );
+ } catch (error) {
+ Logger.error("Error fetching notifications:", error);
+
+ // Handle specific error types if needed
+ if (error && typeof error === "object" && "_tag" in error) {
+ if (error._tag === "UnauthorizedError") {
+ unauthorized();
+ }
+ if (error._tag === "NotFoundError") {
+ notFound();
+ }
+ if (error._tag === "DatabaseError") {
+ return new Response("Database error", { status: 500 });
+ }
+ }
+
+ return new Response("Internal server error", { status: 500 });
+ }
+}
+
+export async function POST(request: NextRequest) {
+ const session = await getSession();
+ if (!session) unauthorized();
+
+ try {
+ const body = createNotificationSchema.safeParse(await request.json());
+ if (!body.success) {
+ return new Response("Invalid request body", { status: 400 });
+ }
+
+ // Determine the target user ID
+ let targetUserId: string;
+
+ if (body.data.userId) {
+ // Explicit user ID provided - verify session user has permission
+ const sessionUser = await getUser(session.user.email);
+ if (!sessionUser) unauthorized();
+
+ // For now, allow any authenticated user to create notifications for any user
+ // TODO: Add role-based checks here
+ targetUserId = body.data.userId;
+ } else {
+ // Fallback to session user (backward compatibility)
+ const sessionUser = await getUser(session.user.email);
+ if (!sessionUser) unauthorized();
+ targetUserId = sessionUser.id;
+ }
+
+ const notification = await notifications.createInAppNotification({
+ title: body.data.title,
+ description: body.data.description,
+ href: body.data.href,
+ userId: targetUserId,
+ });
+
+ return new Response(JSON.stringify(notification), {
+ status: 201,
+ headers: {
+ "Content-Type": "application/json",
+ },
+ });
+ } catch (error) {
+ Logger.error("Error creating notification:", error);
+
+ // Handle specific error types if needed
+ if (error && typeof error === "object" && "_tag" in error) {
+ if (error._tag === "ValidationError") {
+ return new Response("Validation error", { status: 400 });
+ }
+ if (error._tag === "UserNotFoundError") {
+ return new Response("User not found", { status: 404 });
+ }
+ if (error._tag === "UnauthorizedError") {
+ unauthorized();
+ }
+ if (error._tag === "NotFoundError") {
+ notFound();
+ }
+ if (error._tag === "DatabaseError") {
+ return new Response("Database error", { status: 500 });
+ }
+ }
+
+ return new Response("Internal server error", { status: 500 });
+ }
+}
+
+export async function DELETE(request: NextRequest) {
+ const session = await auth();
+ if (!session) unauthorized();
+
+ const user = await getUser(session.user.email);
+ if (!user) unauthorized();
+
+ const searchParams = request.nextUrl.searchParams;
+ const id = searchParams.get("id");
+ if (!id) return new Response("No ID provided", { status: 400 });
+
+ try {
+ await notifications.deleteNotification(user.id, parseInt(id));
+ return new Response("OK", { status: 200 });
+ } catch (error) {
+ Logger.error("Error deleting notification:", error);
+
+ // Handle specific error types if needed
+ if (error && typeof error === "object" && "_tag" in error) {
+ if (error._tag === "UnauthorizedError") {
+ unauthorized();
+ }
+ if (error._tag === "NotFoundError") {
+ notFound();
+ }
+ if (error._tag === "DatabaseError") {
+ return new Response("Database error", { status: 500 });
+ }
+ }
+
+ return new Response("Internal server error", { status: 500 });
+ }
+}
diff --git a/src/app/api/og/route.tsx b/src/app/api/og/route.tsx
index d3f3c920b..0cbf7c977 100644
--- a/src/app/api/og/route.tsx
+++ b/src/app/api/og/route.tsx
@@ -1,7 +1,7 @@
/* eslint-disable @typescript-eslint/require-await */
/* eslint-disable @next/next/no-img-element */
-import Logger from "@/lib/logger";
+import { Logger } from "@/lib/logger";
import { ImageResponse } from "next/og";
export const runtime = "edge";
diff --git a/src/app/api/reporting/submit-bug-report/route.ts b/src/app/api/reporting/submit-bug-report/route.ts
index ca9c9e727..62eea3f92 100644
--- a/src/app/api/reporting/submit-bug-report/route.ts
+++ b/src/app/api/reporting/submit-bug-report/route.ts
@@ -1,15 +1,16 @@
import { getUser } from "@/data/user-dto";
+import { auditLog } from "@/lib/audit-logs";
import {
newBugReportWebhookConstructor,
sendDiscordWebhook,
} from "@/lib/webhooks";
-import { NextRequest, userAgent } from "next/server";
+import { after, type NextRequest, userAgent } from "next/server";
import { z } from "zod";
const BugReportSchema = z.object({
title: z.string().min(1),
description: z.string().min(1),
- email: z.string().email(),
+ email: z.email(),
url: z.string().min(1),
});
@@ -35,5 +36,14 @@ export async function POST(req: NextRequest) {
await sendDiscordWebhook(process.env.BUG_REPORT_WEBHOOK_URL, wh);
+ after(async () => {
+ await auditLog.createAuditLog({
+ userEmail: user?.email ?? "Unknown",
+ action: "BUG_REPORT_SUBMITTED",
+ target: body.data.title,
+ details: `Bug report submitted: ${body.data.title}`,
+ });
+ });
+
return new Response("OK", { status: 200 });
}
diff --git a/src/app/api/scrim/add-map/route.ts b/src/app/api/scrim/add-map/route.ts
index 88d83cb5e..2c29295af 100644
--- a/src/app/api/scrim/add-map/route.ts
+++ b/src/app/api/scrim/add-map/route.ts
@@ -1,9 +1,11 @@
+import { auditLog } from "@/lib/audit-logs";
import { auth } from "@/lib/auth";
-import Logger from "@/lib/logger";
+import { Logger } from "@/lib/logger";
import { createNewMap } from "@/lib/parser";
-import { ParserData } from "@/types/parser";
+import type { ParserData } from "@/types/parser";
import { track } from "@vercel/analytics/server";
-import { NextRequest } from "next/server";
+import { unauthorized } from "next/navigation";
+import { after, type NextRequest } from "next/server";
export async function POST(req: NextRequest) {
const session = await auth();
@@ -14,13 +16,23 @@ export async function POST(req: NextRequest) {
if (!session || !session.user || !session.user.email) {
Logger.warn("Unauthorized request to add map");
- return new Response("Unauthorized", { status: 401 });
+ unauthorized();
}
try {
await createNewMap({ map: data, scrimId: parseInt(id) }, session);
- await track("Create Map", { user: session.user.email });
+ after(async () => {
+ await Promise.all([
+ track("Create Map", { user: session.user.email }),
+ auditLog.createAuditLog({
+ userEmail: session.user.email,
+ action: "MAP_CREATED",
+ target: id,
+ details: `Map created: ${id}`,
+ }),
+ ]);
+ });
return new Response("OK", { status: 200 });
} catch (e: unknown) {
diff --git a/src/app/api/scrim/create-scrim/route.ts b/src/app/api/scrim/create-scrim/route.ts
index 5d226471f..970ad7fc9 100644
--- a/src/app/api/scrim/create-scrim/route.ts
+++ b/src/app/api/scrim/create-scrim/route.ts
@@ -1,16 +1,19 @@
import { getUser } from "@/data/user-dto";
+import { auditLog } from "@/lib/audit-logs";
import { auth } from "@/lib/auth";
-import Logger from "@/lib/logger";
+import { Logger } from "@/lib/logger";
import { createNewScrimFromParsedData } from "@/lib/parser";
import {
newSuspiciousActivityWebhookConstructor,
sendDiscordWebhook,
} from "@/lib/webhooks";
-import { ParserData } from "@/types/parser";
-import { User } from "@prisma/client";
+import type { ParserData } from "@/types/parser";
+import type { User } from "@prisma/client";
import { Ratelimit } from "@upstash/ratelimit";
+import { ipAddress } from "@vercel/functions";
import { kv } from "@vercel/kv";
-import { NextRequest, userAgent } from "next/server";
+import { unauthorized } from "next/navigation";
+import { after, type NextRequest, userAgent } from "next/server";
export type CreateScrimRequestData = {
name: string;
@@ -25,7 +28,7 @@ export async function POST(request: NextRequest) {
if (!session) {
Logger.warn("Unauthorized request to create scrim");
- return new Response("Unauthorized", { status: 401 });
+ unauthorized();
}
// Create a new ratelimiter, that allows 5 requests per 1 minute
@@ -36,7 +39,7 @@ export async function POST(request: NextRequest) {
});
// Limit the requests to 5 per minute per user
- const identifier = request.ip ?? "127.0.0.1";
+ const identifier = ipAddress(request) ?? "127.0.0.1";
const { success } = await ratelimit.limit(identifier);
if (!success) {
@@ -46,6 +49,7 @@ export async function POST(request: NextRequest) {
const user = await getUser(session.user.email);
+ // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const fallbackUser = {
name: "Unknown",
email: "unknown",
@@ -62,6 +66,15 @@ export async function POST(request: NextRequest) {
);
await sendDiscordWebhook(process.env.DISCORD_WEBHOOK_URL, wh);
+ after(async () => {
+ await auditLog.createAuditLog({
+ userEmail: "System",
+ action: "SUSPICIOUS_ACTIVITY_DETECTED",
+ target: `${user?.email ?? "Unknown"}`,
+ details: `Scrim creation (rate limit exceeded) by ${user?.name} (${user?.email})`,
+ });
+ });
+
return new Response("Rate limit exceeded", { status: 429 });
}
@@ -76,5 +89,14 @@ export async function POST(request: NextRequest) {
await createNewScrimFromParsedData(data, session);
+ after(async () => {
+ await auditLog.createAuditLog({
+ userEmail: session.user.email,
+ action: "SCRIM_CREATED",
+ target: `${data.name} (Team: ${data.team})`,
+ details: `Scrim created: ${data.name}`,
+ });
+ });
+
return new Response("OK", { status: 200 });
}
diff --git a/src/app/api/scrim/remove-map/route.ts b/src/app/api/scrim/remove-map/route.ts
index c19646f1e..731e0d665 100644
--- a/src/app/api/scrim/remove-map/route.ts
+++ b/src/app/api/scrim/remove-map/route.ts
@@ -1,10 +1,12 @@
import { getScrim } from "@/data/scrim-dto";
import { getUser } from "@/data/user-dto";
+import { auditLog } from "@/lib/audit-logs";
import { auth } from "@/lib/auth";
-import Logger from "@/lib/logger";
+import { Logger } from "@/lib/logger";
import prisma from "@/lib/prisma";
import { $Enums } from "@prisma/client";
-import { NextRequest } from "next/server";
+import { unauthorized } from "next/navigation";
+import { after, type NextRequest } from "next/server";
export async function POST(req: NextRequest) {
const params = req.nextUrl.searchParams;
@@ -16,7 +18,7 @@ export async function POST(req: NextRequest) {
if (!session) {
if (token !== process.env.DEV_TOKEN) {
Logger.warn("Unauthorized request to remove map: ", id);
- return new Response("Unauthorized", { status: 401 });
+ unauthorized();
}
Logger.log("Authorized removal of map with dev token");
}
@@ -36,7 +38,7 @@ export async function POST(req: NextRequest) {
let isManager = false;
- if (scrim.teamId !== 0) {
+ if (scrim.teamId !== 0 && scrim.teamId !== null) {
// scrim is associated with a team
const managers = await prisma.team.findFirst({
where: { id: scrim.teamId ?? 0 },
@@ -55,7 +57,7 @@ export async function POST(req: NextRequest) {
isManager; // Managers of the scrim's team can delete the map
if (!hasPerms) {
- return new Response("Unauthorized", { status: 401 });
+ unauthorized();
}
Logger.log("Removing map: ", id);
@@ -90,5 +92,14 @@ export async function POST(req: NextRequest) {
prisma.ultimateStart.deleteMany({ where: { MapDataId: mapId } }),
]);
+ after(async () => {
+ await auditLog.createAuditLog({
+ userEmail: user.email,
+ action: "MAP_DELETED",
+ target: `${scrim.name} (ID: ${scrim.id})`,
+ details: `Map deleted: ${id}`,
+ });
+ });
+
return new Response("OK", { status: 200 });
}
diff --git a/src/app/api/scrim/remove-scrim/route.ts b/src/app/api/scrim/remove-scrim/route.ts
index d6e13e06e..71dc9c258 100644
--- a/src/app/api/scrim/remove-scrim/route.ts
+++ b/src/app/api/scrim/remove-scrim/route.ts
@@ -1,10 +1,13 @@
import { getScrim } from "@/data/scrim-dto";
import { getUser } from "@/data/user-dto";
+import { auditLog } from "@/lib/audit-logs";
import { auth } from "@/lib/auth";
-import Logger from "@/lib/logger";
+import { Logger } from "@/lib/logger";
+import { notifications } from "@/lib/notifications";
import prisma from "@/lib/prisma";
import { $Enums } from "@prisma/client";
-import { NextRequest } from "next/server";
+import { unauthorized } from "next/navigation";
+import { after, type NextRequest } from "next/server";
export async function POST(req: NextRequest) {
const params = req.nextUrl.searchParams;
@@ -16,7 +19,7 @@ export async function POST(req: NextRequest) {
if (!session) {
if (token !== process.env.DEV_TOKEN) {
Logger.warn("Unauthorized request to remove scrim: ", id);
- return new Response("Unauthorized", { status: 401 });
+ unauthorized();
}
Logger.log("Authorized removal of scrim with dev token");
}
@@ -50,10 +53,30 @@ export async function POST(req: NextRequest) {
user.id === scrim.creatorId || // Creators can delete their own scrims
isManager; // Managers of the scrim's team can delete the scrim
- if (!hasPerms) return new Response("Unauthorized", { status: 401 });
+ if (!hasPerms) unauthorized();
const scrimId = parseInt(id);
+ // If not an individual scrim, create a notification for the team
+ if (scrim.teamId !== 0 && scrim.teamId !== null) {
+ const team = await prisma.team.findFirst({
+ where: { id: scrim.teamId ?? 0 },
+ });
+ if (!team) return new Response("Team not found", { status: 404 });
+
+ const teamMembers = await prisma.user.findMany({
+ where: { teams: { some: { id: scrim.teamId ?? 0 } } },
+ });
+
+ for (const member of teamMembers) {
+ await notifications.createInAppNotification({
+ userId: member.id,
+ title: `${team.name}: Scrim ${scrim.name} has been deleted`,
+ description: `Scrim "${scrim.name}" has been deleted by ${user.name}.`,
+ });
+ }
+ }
+
await Promise.all([
prisma.scrim.delete({ where: { id: scrimId } }),
prisma.map.deleteMany({ where: { scrimId } }),
@@ -83,5 +106,14 @@ export async function POST(req: NextRequest) {
prisma.ultimateStart.deleteMany({ where: { scrimId } }),
]);
+ after(async () => {
+ await auditLog.createAuditLog({
+ userEmail: user.email,
+ action: "SCRIM_DELETED",
+ target: `${scrim.name} (Team: ${scrim.teamId ?? "Individual"})`,
+ details: `Scrim deleted: ${id}`,
+ });
+ });
+
return new Response("OK", { status: 200 });
}
diff --git a/src/app/api/scrim/update-scrim-options/route.ts b/src/app/api/scrim/update-scrim-options/route.ts
index c07ebebd0..16ddd335d 100644
--- a/src/app/api/scrim/update-scrim-options/route.ts
+++ b/src/app/api/scrim/update-scrim-options/route.ts
@@ -1,16 +1,18 @@
import { getScrim } from "@/data/scrim-dto";
import { getUser } from "@/data/user-dto";
+import { auditLog } from "@/lib/audit-logs";
import { auth } from "@/lib/auth";
import prisma from "@/lib/prisma";
import { $Enums } from "@prisma/client";
-import { NextRequest } from "next/server";
+import { unauthorized } from "next/navigation";
+import { after, type NextRequest } from "next/server";
import { z } from "zod";
const UpdateScrimSchema = z.object({
name: z.string().min(1).max(30),
teamId: z.string().min(1),
scrimId: z.number(),
- date: z.string().datetime(),
+ date: z.iso.datetime(),
guestMode: z.boolean(),
maps: z.array(
z.object({
@@ -23,14 +25,14 @@ const UpdateScrimSchema = z.object({
export async function POST(req: NextRequest) {
const session = await auth();
if (!session || !session.user || !session.user.email) {
- return new Response("Unauthorized", { status: 401 });
+ unauthorized();
}
const body = UpdateScrimSchema.safeParse(await req.json());
if (!body.success) return new Response("Invalid request", { status: 400 });
const user = await getUser(session.user.email);
- if (!user) return new Response("Unauthorized", { status: 401 });
+ if (!user) unauthorized();
const userIsManager = await prisma.teamManager.findFirst({
where: { userId: user.id },
@@ -45,7 +47,7 @@ export async function POST(req: NextRequest) {
user.role === $Enums.UserRole.ADMIN || // user is an admin
user.role === $Enums.UserRole.MANAGER; // user is a manager
- if (!hasPerms) return new Response("Unauthorized", { status: 401 });
+ if (!hasPerms) unauthorized();
await prisma.scrim.update({
where: { id: body.data.scrimId },
@@ -66,5 +68,14 @@ export async function POST(req: NextRequest) {
}
}
+ after(async () => {
+ await auditLog.createAuditLog({
+ userEmail: user.email,
+ action: "SCRIM_UPDATED",
+ target: `${scrim.name} (ID: ${scrim.id})`,
+ details: `Scrim updated: ${scrim.name} (ID: ${scrim.id})`,
+ });
+ });
+
return new Response("OK", { status: 200 });
}
diff --git a/src/app/api/stripe/webhooks/route.ts b/src/app/api/stripe/webhooks/route.ts
index 45a47452f..3d46ad8a4 100644
--- a/src/app/api/stripe/webhooks/route.ts
+++ b/src/app/api/stripe/webhooks/route.ts
@@ -1,9 +1,8 @@
-/* eslint-disable no-case-declarations */
import { handleSubscriptionEvent } from "@/lib/billing-plans";
-import Logger from "@/lib/logger";
+import { Logger } from "@/lib/logger";
import { stripe } from "@/lib/stripe";
import { track } from "@vercel/analytics/server";
-import Stripe from "stripe";
+import type Stripe from "stripe";
const relevantEvents = new Set([
"customer.created",
@@ -16,7 +15,7 @@ const relevantEvents = new Set([
export async function POST(req: Request) {
const body = await req.text();
- const sig = req.headers.get("stripe-signature") as string;
+ const sig = req.headers.get("stripe-signature")!;
const webhookSecret =
process.env.NODE_ENV === "production"
? process.env.STRIPE_WEBHOOK_SECRET
@@ -39,11 +38,12 @@ export async function POST(req: Request) {
if (relevantEvents.has(event.type)) {
try {
switch (event.type) {
- case "customer.created":
+ case "customer.created": {
const customer = event.data.object;
Logger.log("New customer", customer);
await track("New Customer", { customerId: customer.id });
break;
+ }
case "customer.deleted":
Logger.log("Deleted customer", event.data.object);
await track("Deleted Customer", { customerId: event.data.object.id });
@@ -53,7 +53,7 @@ export async function POST(req: Request) {
case "customer.subscription.deleted":
await handleSubscriptionEvent(event, event.type);
break;
- case "checkout.session.completed":
+ case "checkout.session.completed": {
const checkoutSession = event.data.object;
if (checkoutSession.mode === "subscription") {
const subscriptionId = checkoutSession.subscription;
@@ -64,6 +64,7 @@ export async function POST(req: Request) {
);
}
break;
+ }
default:
throw new Error("Unhandled relevant event!");
}
diff --git a/src/app/api/team/avatar-upload/route.ts b/src/app/api/team/avatar-upload/route.ts
index d62ff787d..4ceb26a96 100644
--- a/src/app/api/team/avatar-upload/route.ts
+++ b/src/app/api/team/avatar-upload/route.ts
@@ -1,18 +1,17 @@
import { getUser } from "@/data/user-dto";
import { auth } from "@/lib/auth";
-import Logger from "@/lib/logger";
+import { Logger } from "@/lib/logger";
import prisma from "@/lib/prisma";
import { Ratelimit } from "@upstash/ratelimit";
import { track } from "@vercel/analytics/server";
import { handleUpload, type HandleUploadBody } from "@vercel/blob/client";
import { kv } from "@vercel/kv";
-import { NextRequest, NextResponse } from "next/server";
+import { unauthorized } from "next/navigation";
+import { type NextRequest, NextResponse } from "next/server";
export async function POST(request: NextRequest): Promise {
const session = await auth();
- if (!session || !session.user) {
- return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
- }
+ if (!session?.user) unauthorized();
const userId = await getUser(session.user.email);
if (!userId) {
@@ -48,20 +47,19 @@ export async function POST(request: NextRequest): Promise {
const jsonResponse = await handleUpload({
body,
request,
- onBeforeGenerateToken: async (
- pathname: string
+ onBeforeGenerateToken: async () =>
/* clientPayload?: string, */
- ) => {
- // Generate a client token for the browser to upload the file
- // ⚠️ Authenticate and authorize users before generating the token.
- // Otherwise, you're allowing anonymous uploads.
- const team = await prisma.team.findUnique({
- where: { id: parseInt(teamId) },
- });
- if (!team) throw new Error("Team not found");
+ {
+ // Generate a client token for the browser to upload the file
+ // ⚠️ Authenticate and authorize users before generating the token.
+ // Otherwise, you're allowing anonymous uploads.
+ const team = await prisma.team.findUnique({
+ where: { id: parseInt(teamId) },
+ });
+ if (!team) throw new Error("Team not found");
- return { tokenPayload: JSON.stringify({ teamId: team.id }) };
- },
+ return { tokenPayload: JSON.stringify({ teamId: team.id }) };
+ },
onUploadCompleted: async ({ blob, tokenPayload }) => {
// Get notified of client upload completion
// ⚠️ This will not work on `localhost` websites,
@@ -83,7 +81,7 @@ export async function POST(request: NextRequest): Promise {
where: { id: team.id },
data: { image: blob.url },
});
- } catch (error) {
+ } catch {
throw new Error("Could not update team");
}
},
diff --git a/src/app/api/team/create-team-invite/route.ts b/src/app/api/team/create-team-invite/route.ts
index 0739d6a98..ff06e243d 100644
--- a/src/app/api/team/create-team-invite/route.ts
+++ b/src/app/api/team/create-team-invite/route.ts
@@ -1,8 +1,9 @@
import { auth } from "@/lib/auth";
import { generateRandomToken } from "@/lib/invite-token";
-import Logger from "@/lib/logger";
+import { Logger } from "@/lib/logger";
import prisma from "@/lib/prisma";
-import { NextRequest } from "next/server";
+import { unauthorized } from "next/navigation";
+import type { NextRequest } from "next/server";
const FREE_MEMBER_CAP = 5;
const BASIC_MEMBER_CAP = 10;
@@ -17,13 +18,13 @@ export async function POST(req: NextRequest) {
if (!session) {
if (token !== process.env.DEV_TOKEN) {
Logger.warn("Unauthorized request to create team invite");
- return new Response("Unauthorized", { status: 401 });
+ unauthorized();
}
Logger.log("Authorized request to create team invite using dev token");
}
const teamId = req.nextUrl.searchParams.get("id")
- ? parseInt(req.nextUrl.searchParams.get("id") as string)
+ ? parseInt(req.nextUrl.searchParams.get("id")!)
: null;
if (!teamId) {
@@ -78,8 +79,7 @@ export async function POST(req: NextRequest) {
}
break;
default:
- if (teamCreator.role !== "ADMIN")
- return new Response("Unauthorized", { status: 401 });
+ if (teamCreator.role !== "ADMIN") unauthorized();
break;
}
diff --git a/src/app/api/team/create-team/route.ts b/src/app/api/team/create-team/route.ts
index 8d0efafe1..02557b045 100644
--- a/src/app/api/team/create-team/route.ts
+++ b/src/app/api/team/create-team/route.ts
@@ -1,11 +1,13 @@
import { getUser } from "@/data/user-dto";
+import { auditLog } from "@/lib/audit-logs";
import { auth } from "@/lib/auth";
-import Logger from "@/lib/logger";
+import { Logger } from "@/lib/logger";
import { Permission } from "@/lib/permissions";
import prisma from "@/lib/prisma";
import { Ratelimit } from "@upstash/ratelimit";
+import { ipAddress } from "@vercel/functions";
import { kv } from "@vercel/kv";
-import { NextRequest } from "next/server";
+import { after, type NextRequest } from "next/server";
import { z } from "zod";
const TeamCreationRequestSchema = z.object({
@@ -21,7 +23,7 @@ export async function POST(request: NextRequest) {
});
// Limit the requests to 5 per minute per user
- const identifier = request.ip ?? "127.0.0.1";
+ const identifier = ipAddress(request) ?? "127.0.0.1";
const { success } = await ratelimit.limit(identifier);
if (!success) {
@@ -107,5 +109,14 @@ export async function POST(request: NextRequest) {
data: { teams: { connect: [{ id: team.id }] } },
});
+ after(async () => {
+ await auditLog.createAuditLog({
+ userEmail: session.user.email,
+ action: "TEAM_CREATED",
+ target: team.name,
+ details: `Team created: ${team.name}`,
+ });
+ });
+
return new Response("Success", { status: 200 });
}
diff --git a/src/app/api/team/delete-expired-tokens/route.ts b/src/app/api/team/delete-expired-tokens/route.ts
index 1f485a117..c48389890 100644
--- a/src/app/api/team/delete-expired-tokens/route.ts
+++ b/src/app/api/team/delete-expired-tokens/route.ts
@@ -1,4 +1,4 @@
-import Logger from "@/lib/logger";
+import { Logger } from "@/lib/logger";
import prisma from "@/lib/prisma";
export async function DELETE() {
diff --git a/src/app/api/team/demote-user/route.ts b/src/app/api/team/demote-user/route.ts
index 1086a71f4..853bb2c2e 100644
--- a/src/app/api/team/demote-user/route.ts
+++ b/src/app/api/team/demote-user/route.ts
@@ -1,8 +1,10 @@
import { getUser } from "@/data/user-dto";
+import { auditLog } from "@/lib/audit-logs";
import { auth } from "@/lib/auth";
-import Logger from "@/lib/logger";
+import { Logger } from "@/lib/logger";
import prisma from "@/lib/prisma";
-import { NextRequest } from "next/server";
+import { unauthorized } from "next/navigation";
+import { after, type NextRequest } from "next/server";
import { z } from "zod";
const DemoteUserSchema = z.object({
@@ -16,7 +18,7 @@ export async function POST(req: NextRequest) {
const devTokenAuthed = authToken === process.env.DEV_TOKEN;
if (!session || !session.user || !session.user.email) {
- if (!devTokenAuthed) return new Response("Unauthorized", { status: 401 });
+ if (!devTokenAuthed) unauthorized();
Logger.log("Authorized with dev token");
}
@@ -33,9 +35,7 @@ export async function POST(req: NextRequest) {
const authedUser = await getUser(session?.user?.email);
- if (!authedUser && !devTokenAuthed) {
- return new Response("Unauthorized", { status: 401 });
- }
+ if (!authedUser && !devTokenAuthed) unauthorized();
if (!devTokenAuthed) {
const managers = await prisma.team.findFirst({
@@ -47,9 +47,7 @@ export async function POST(req: NextRequest) {
(manager) => manager.userId === authedUser?.id
);
- if (team.ownerId !== authedUser?.id && !isManager) {
- return new Response("Unauthorized", { status: 401 });
- }
+ if (team.ownerId !== authedUser?.id && !isManager) unauthorized();
}
// demote user to member
@@ -57,5 +55,14 @@ export async function POST(req: NextRequest) {
where: { userId: user.id, teamId: team.id },
});
+ after(async () => {
+ await auditLog.createAuditLog({
+ userEmail: authedUser!.email,
+ action: "TEAM_MEMBER_DEMOTED",
+ target: team.name,
+ details: `Demoted ${user.name} to member`,
+ });
+ });
+
return new Response("OK", { status: 200 });
}
diff --git a/src/app/api/team/get-teams-with-perms/route.ts b/src/app/api/team/get-teams-with-perms/route.ts
index dcc3d6b0c..9f30bfb92 100644
--- a/src/app/api/team/get-teams-with-perms/route.ts
+++ b/src/app/api/team/get-teams-with-perms/route.ts
@@ -1,15 +1,16 @@
import { getUser } from "@/data/user-dto";
import { auth } from "@/lib/auth";
-import Logger from "@/lib/logger";
+import { Logger } from "@/lib/logger";
import prisma from "@/lib/prisma";
import { $Enums } from "@prisma/client";
+import { unauthorized } from "next/navigation";
export async function GET() {
const session = await auth();
if (!session) {
Logger.warn("Unauthorized request to get teams API");
- return new Response("Unauthorized", { status: 401 });
+ unauthorized();
}
const userId = await getUser(session?.user?.email);
diff --git a/src/app/api/team/get-teams/route.ts b/src/app/api/team/get-teams/route.ts
index 931faeb9b..990da32f8 100644
--- a/src/app/api/team/get-teams/route.ts
+++ b/src/app/api/team/get-teams/route.ts
@@ -1,8 +1,9 @@
import { getUser } from "@/data/user-dto";
import { auth } from "@/lib/auth";
-import Logger from "@/lib/logger";
+import { Logger } from "@/lib/logger";
import prisma from "@/lib/prisma";
-import { Team } from "@prisma/client";
+import type { Team } from "@prisma/client";
+import { unauthorized } from "next/navigation";
export type GetTeamsResponse = {
teams: Team[];
@@ -13,7 +14,7 @@ export async function GET() {
if (!session) {
Logger.warn("Unauthorized request to get teams API");
- return new Response("Unauthorized", { status: 401 });
+ unauthorized();
}
const userId = await getUser(session?.user?.email);
diff --git a/src/app/api/team/join-team/route.ts b/src/app/api/team/join-team/route.ts
index a9d79acd7..7beb433b6 100644
--- a/src/app/api/team/join-team/route.ts
+++ b/src/app/api/team/join-team/route.ts
@@ -1,7 +1,9 @@
+import { auditLog } from "@/lib/audit-logs";
import { auth } from "@/lib/auth";
-import Logger from "@/lib/logger";
+import { Logger } from "@/lib/logger";
import prisma from "@/lib/prisma";
-import { NextRequest } from "next/server";
+import { unauthorized } from "next/navigation";
+import { after, type NextRequest } from "next/server";
export async function POST(req: NextRequest) {
const session = await auth();
@@ -12,7 +14,7 @@ export async function POST(req: NextRequest) {
if (!session) {
if (authToken !== process.env.DEV_TOKEN) {
Logger.warn("Unauthorized request to create team invite");
- return new Response("Unauthorized", { status: 401 });
+ unauthorized();
}
Logger.log("Authorized request to create team invite using dev token");
}
@@ -54,5 +56,14 @@ export async function POST(req: NextRequest) {
Logger.log(`User now belongs to team: ${JSON.stringify(teams)}`);
+ after(async () => {
+ await auditLog.createAuditLog({
+ userEmail: session?.user?.email ?? testingEmail,
+ action: "TEAM_JOINED",
+ target: `${teams[0].name}`,
+ details: `Joined team ${teams[0].name} (Team ID: ${teamInviteToken.teamId})`,
+ });
+ });
+
return new Response("OK", { status: 200 });
}
diff --git a/src/app/api/team/promote-user/route.ts b/src/app/api/team/promote-user/route.ts
index 5f30051f4..4b307cb0f 100644
--- a/src/app/api/team/promote-user/route.ts
+++ b/src/app/api/team/promote-user/route.ts
@@ -1,8 +1,10 @@
import { getUser } from "@/data/user-dto";
+import { auditLog } from "@/lib/audit-logs";
import { auth } from "@/lib/auth";
-import Logger from "@/lib/logger";
+import { Logger } from "@/lib/logger";
import prisma from "@/lib/prisma";
-import { NextRequest } from "next/server";
+import { unauthorized } from "next/navigation";
+import { after, type NextRequest } from "next/server";
import { z } from "zod";
const PromoteUserSchema = z.object({
@@ -16,9 +18,8 @@ export async function POST(req: NextRequest) {
const devTokenAuthed = authToken === process.env.DEV_TOKEN;
if (!session || !session.user || !session.user.email) {
- if (!devTokenAuthed) {
- return new Response("Unauthorized", { status: 401 });
- }
+ if (!devTokenAuthed) unauthorized();
+
Logger.log("Authorized with dev token");
}
@@ -53,9 +54,7 @@ export async function POST(req: NextRequest) {
(manager) => manager.userId === authedUser?.id
);
- if (team.ownerId !== authedUser?.id && !isManager) {
- return new Response("Unauthorized", { status: 401 });
- }
+ if (team.ownerId !== authedUser?.id && !isManager) unauthorized();
}
// add user as a manager
@@ -63,5 +62,14 @@ export async function POST(req: NextRequest) {
data: { userId, teamId: parseInt(teamId) },
});
+ after(async () => {
+ await auditLog.createAuditLog({
+ userEmail: authedUser!.email,
+ action: "TEAM_MEMBER_PROMOTED",
+ target: user.email,
+ details: `Promoted ${user.name ?? user.email} to manager of team ${team.name}`,
+ });
+ });
+
return new Response("OK", { status: 200 });
}
diff --git a/src/app/api/team/remove-team/route.ts b/src/app/api/team/remove-team/route.ts
index 9fbc43c44..a81909d4b 100644
--- a/src/app/api/team/remove-team/route.ts
+++ b/src/app/api/team/remove-team/route.ts
@@ -1,9 +1,11 @@
import { getUser } from "@/data/user-dto";
+import { auditLog } from "@/lib/audit-logs";
import { auth } from "@/lib/auth";
-import Logger from "@/lib/logger";
+import { Logger } from "@/lib/logger";
import prisma from "@/lib/prisma";
import { $Enums } from "@prisma/client";
-import { NextRequest } from "next/server";
+import { unauthorized } from "next/navigation";
+import { after, type NextRequest } from "next/server";
export async function POST(req: NextRequest) {
const params = req.nextUrl.searchParams;
@@ -18,7 +20,7 @@ export async function POST(req: NextRequest) {
if (!session) {
if (token !== process.env.DEV_TOKEN) {
Logger.warn("Unauthorized request to remove team: ", id);
- return new Response("Unauthorized", { status: 401 });
+ unauthorized();
}
Logger.log("Authorized removal of team with dev token");
}
@@ -34,12 +36,19 @@ export async function POST(req: NextRequest) {
user.role === $Enums.UserRole.MANAGER || // Managers can delete anything
user.id === team.ownerId; // Creators can delete their own teams
- if (!hasPerms) {
- return new Response("Unauthorized", { status: 401 });
- }
+ if (!hasPerms) unauthorized();
await prisma.team.delete({ where: { id } });
await prisma.teamManager.deleteMany({ where: { teamId: id } });
+ after(async () => {
+ await auditLog.createAuditLog({
+ userEmail: user.email,
+ action: "TEAM_DELETED",
+ target: team.name,
+ details: `Team deleted: ${team.name}`,
+ });
+ });
+
return new Response("OK", { status: 200 });
}
diff --git a/src/app/api/team/remove-user-from-team/route.ts b/src/app/api/team/remove-user-from-team/route.ts
index 0b4bf8319..62fec79cd 100644
--- a/src/app/api/team/remove-user-from-team/route.ts
+++ b/src/app/api/team/remove-user-from-team/route.ts
@@ -1,8 +1,10 @@
import { getUser } from "@/data/user-dto";
+import { auditLog } from "@/lib/audit-logs";
import { auth } from "@/lib/auth";
import prisma from "@/lib/prisma";
-import { User } from "@prisma/client";
-import { NextRequest } from "next/server";
+import type { User } from "@prisma/client";
+import { unauthorized } from "next/navigation";
+import { after, type NextRequest } from "next/server";
import { z } from "zod";
const RemoveUserFromTeamSchema = z.object({
@@ -12,9 +14,7 @@ const RemoveUserFromTeamSchema = z.object({
export async function POST(req: NextRequest) {
const session = await auth();
- if (!session || !session.user || !session.user.email) {
- return new Response("Unauthorized", { status: 401 });
- }
+ if (!session || !session.user || !session.user.email) unauthorized();
const body = RemoveUserFromTeamSchema.safeParse(await req.json());
if (!body.success) return new Response("Invalid request", { status: 400 });
@@ -28,7 +28,7 @@ export async function POST(req: NextRequest) {
if (!user) return new Response("User not found", { status: 404 });
const authedUser = await getUser(session.user.email);
- if (!authedUser) return new Response("Unauthorized", { status: 401 });
+ if (!authedUser) unauthorized();
const managers = await prisma.team.findFirst({
where: { id: parseInt(body.data.teamId) },
@@ -44,7 +44,7 @@ export async function POST(req: NextRequest) {
}
if (team.ownerId !== authedUser.id && !authedUserIsManager) {
- return new Response("Unauthorized", { status: 401 });
+ unauthorized();
}
if (team.ownerId === user.id) {
@@ -75,5 +75,14 @@ export async function POST(req: NextRequest) {
data: { users: { disconnect: { id: body.data.userId } } },
});
+ after(async () => {
+ await auditLog.createAuditLog({
+ userEmail: authedUser.email,
+ action: "TEAM_MEMBER_REMOVED",
+ target: user.email,
+ details: `Removed ${user.name ?? user.email} from team ${team.name}`,
+ });
+ });
+
return new Response("OK", { status: 200 });
}
diff --git a/src/app/api/team/transfer-ownership/route.ts b/src/app/api/team/transfer-ownership/route.ts
index d60b3dccd..9650f7e71 100644
--- a/src/app/api/team/transfer-ownership/route.ts
+++ b/src/app/api/team/transfer-ownership/route.ts
@@ -1,9 +1,11 @@
import { getUser } from "@/data/user-dto";
+import { auditLog } from "@/lib/audit-logs";
import { auth } from "@/lib/auth";
-import Logger from "@/lib/logger";
+import { Logger } from "@/lib/logger";
import prisma from "@/lib/prisma";
import { $Enums } from "@prisma/client";
-import { NextRequest } from "next/server";
+import { forbidden, unauthorized } from "next/navigation";
+import { after, type NextRequest } from "next/server";
export async function POST(req: NextRequest) {
const params = req.nextUrl.searchParams;
@@ -20,7 +22,7 @@ export async function POST(req: NextRequest) {
if (!session) {
if (token !== process.env.DEV_TOKEN) {
Logger.warn("Unauthorized request to remove team: ", id);
- return new Response("Unauthorized", { status: 401 });
+ unauthorized();
}
Logger.log("Authorized removal of team with dev token");
}
@@ -36,7 +38,7 @@ export async function POST(req: NextRequest) {
user.role === $Enums.UserRole.MANAGER || // Managers can transfer anything
user.id === team.ownerId; // Creators can transfer their own teams
- if (!hasPerms) return new Response("Unauthorized", { status: 401 });
+ if (!hasPerms) forbidden();
const newOwner = await getUser(owner);
if (!newOwner) return new Response("New owner not found", { status: 404 });
@@ -52,5 +54,14 @@ export async function POST(req: NextRequest) {
data: { users: { connect: { id: newOwner.id } } },
});
+ after(async () => {
+ await auditLog.createAuditLog({
+ userEmail: user.email,
+ action: "TEAM_OWNERSHIP_TRANSFERRED",
+ target: newOwner.email,
+ details: `Transferred ownership of team ${team.name} to ${newOwner.name}`,
+ });
+ });
+
return new Response("OK", { status: 200 });
}
diff --git a/src/app/api/team/update-avatar/route.ts b/src/app/api/team/update-avatar/route.ts
index 2c909ed23..ce1271118 100644
--- a/src/app/api/team/update-avatar/route.ts
+++ b/src/app/api/team/update-avatar/route.ts
@@ -1,8 +1,10 @@
import { getUser } from "@/data/user-dto";
+import { auditLog } from "@/lib/audit-logs";
import { auth } from "@/lib/auth";
-import Logger from "@/lib/logger";
+import { Logger } from "@/lib/logger";
import prisma from "@/lib/prisma";
-import { NextRequest } from "next/server";
+import { unauthorized } from "next/navigation";
+import { after, type NextRequest } from "next/server";
import { z } from "zod";
const TeamAvatarUpdateSchema = z.object({
@@ -12,9 +14,9 @@ const TeamAvatarUpdateSchema = z.object({
export async function POST(req: NextRequest) {
const session = await auth();
- if (!session || !session.user) {
+ if (!session?.user) {
Logger.log("No session found", session);
- return new Response("Unauthorized", { status: 401 });
+ unauthorized();
}
const body = TeamAvatarUpdateSchema.safeParse(await req.json());
@@ -35,7 +37,7 @@ export async function POST(req: NextRequest) {
const authedUser = await getUser(session.user.email);
if (!authedUser) {
Logger.log("User not found", session.user.email);
- return new Response("Unauthorized", { status: 401 });
+ unauthorized();
}
if (
@@ -43,7 +45,7 @@ export async function POST(req: NextRequest) {
teamManagers.some((m) => m.userId === authedUser.id) === false
) {
Logger.log("Not a team owner or manager", team.name, authedUser.email);
- return new Response("Unauthorized", { status: 401 });
+ unauthorized();
}
await prisma.team.update({
@@ -53,5 +55,14 @@ export async function POST(req: NextRequest) {
Logger.log("new avatar uploaded for team: ", team.name, body.data.image);
+ after(async () => {
+ await auditLog.createAuditLog({
+ userEmail: authedUser.email,
+ action: "TEAM_AVATAR_UPDATED",
+ target: team.name,
+ details: `Updated avatar for team ${team.name}`,
+ });
+ });
+
return new Response("OK", { status: 200 });
}
diff --git a/src/app/api/team/update-name/route.ts b/src/app/api/team/update-name/route.ts
index 8afde6c84..175aaad98 100644
--- a/src/app/api/team/update-name/route.ts
+++ b/src/app/api/team/update-name/route.ts
@@ -1,6 +1,8 @@
+import { auditLog } from "@/lib/audit-logs";
import { auth } from "@/lib/auth";
import prisma from "@/lib/prisma";
-import { NextRequest } from "next/server";
+import { unauthorized } from "next/navigation";
+import { after, type NextRequest } from "next/server";
import { z } from "zod";
const TeamNameUpdateSchema = z.object({
@@ -21,7 +23,7 @@ const TeamNameUpdateSchema = z.object({
export async function POST(req: NextRequest) {
const session = await auth();
if (!session || !session.user || !session.user.email) {
- return new Response("Unauthorized", { status: 401 });
+ unauthorized();
}
const body = TeamNameUpdateSchema.safeParse(await req.json());
@@ -35,5 +37,14 @@ export async function POST(req: NextRequest) {
},
});
+ after(async () => {
+ await auditLog.createAuditLog({
+ userEmail: session.user.email,
+ action: "TEAM_UPDATED",
+ target: body.data.name,
+ details: `Updated name for team ${body.data.name}`,
+ });
+ });
+
return new Response("OK", { status: 200 });
}
diff --git a/src/app/api/user/avatar-upload/route.ts b/src/app/api/user/avatar-upload/route.ts
index 9495625be..21bc6d699 100644
--- a/src/app/api/user/avatar-upload/route.ts
+++ b/src/app/api/user/avatar-upload/route.ts
@@ -1,18 +1,19 @@
import { getUser } from "@/data/user-dto";
import { auth } from "@/lib/auth";
-import Logger from "@/lib/logger";
+import { Logger } from "@/lib/logger";
import prisma from "@/lib/prisma";
import { Ratelimit } from "@upstash/ratelimit";
import { track } from "@vercel/analytics/server";
import { handleUpload, type HandleUploadBody } from "@vercel/blob/client";
import { kv } from "@vercel/kv";
-import { NextRequest, NextResponse } from "next/server";
+import { unauthorized } from "next/navigation";
+import { type NextRequest, NextResponse } from "next/server";
export async function POST(request: NextRequest): Promise {
const session = await auth();
- if (!session || !session.user) {
- return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ if (!session?.user) {
+ unauthorized();
}
const authedUser = await getUser(session.user.email);
@@ -50,18 +51,18 @@ export async function POST(request: NextRequest): Promise {
const jsonResponse = await handleUpload({
body,
request,
- onBeforeGenerateToken: async (
- pathname: string
+ onBeforeGenerateToken: async () =>
+ // pathname: string
/* clientPayload?: string, */
- ) => {
- // Generate a client token for the browser to upload the file
- // ⚠️ Authenticate and authorize users before generating the token.
- // Otherwise, you're allowing anonymous uploads.
- const user = await prisma.user.findUnique({ where: { id: userId } });
- if (!user) throw new Error("User not found");
-
- return { tokenPayload: JSON.stringify({ userId: user.id }) };
- },
+ {
+ // Generate a client token for the browser to upload the file
+ // ⚠️ Authenticate and authorize users before generating the token.
+ // Otherwise, you're allowing anonymous uploads.
+ const user = await prisma.user.findUnique({ where: { id: userId } });
+ if (!user) throw new Error("User not found");
+
+ return { tokenPayload: JSON.stringify({ userId: user.id }) };
+ },
onUploadCompleted: async ({ blob, tokenPayload }) => {
// Get notified of client upload completion
// ⚠️ This will not work on `localhost` websites,
@@ -81,7 +82,7 @@ export async function POST(request: NextRequest): Promise {
where: { id: user?.id },
data: { image: blob.url },
});
- } catch (error) {
+ } catch {
throw new Error("Could not update user");
}
},
diff --git a/src/app/api/user/delete-account/route.ts b/src/app/api/user/delete-account/route.ts
index 33f0f4e62..1e6670d81 100644
--- a/src/app/api/user/delete-account/route.ts
+++ b/src/app/api/user/delete-account/route.ts
@@ -1,20 +1,22 @@
import { getUser } from "@/data/user-dto";
+import { auditLog } from "@/lib/audit-logs";
import { auth } from "@/lib/auth";
-import Logger from "@/lib/logger";
+import { Logger } from "@/lib/logger";
import prisma from "@/lib/prisma";
import {
deleteUserWebhookConstructor,
sendDiscordWebhook,
} from "@/lib/webhooks";
import { track } from "@vercel/analytics/server";
-import { NextRequest } from "next/server";
+import { unauthorized } from "next/navigation";
+import { after } from "next/server";
-export async function DELETE(req: NextRequest) {
+export async function DELETE() {
const session = await auth();
- if (!session) return new Response("Unauthorized", { status: 401 });
+ if (!session) unauthorized();
const user = await getUser(session.user.email);
- if (!user) return new Response("Unauthorized", { status: 401 });
+ if (!user) unauthorized();
await track("User Deleted Account", { email: user.email });
@@ -25,5 +27,14 @@ export async function DELETE(req: NextRequest) {
await prisma.user.delete({ where: { id: user.id } });
+ after(async () => {
+ await auditLog.createAuditLog({
+ userEmail: user.email,
+ action: "USER_ACCOUNT_DELETED",
+ target: user.email,
+ details: `User deleted their account`,
+ });
+ });
+
return new Response("OK", { status: 200 });
}
diff --git a/src/app/api/user/update-avatar/route.ts b/src/app/api/user/update-avatar/route.ts
index a8ae56746..4018819c7 100644
--- a/src/app/api/user/update-avatar/route.ts
+++ b/src/app/api/user/update-avatar/route.ts
@@ -1,19 +1,19 @@
+import { auditLog } from "@/lib/audit-logs";
import { auth } from "@/lib/auth";
-import Logger from "@/lib/logger";
+import { Logger } from "@/lib/logger";
import prisma from "@/lib/prisma";
-import { NextRequest } from "next/server";
+import { unauthorized } from "next/navigation";
+import { after, type NextRequest } from "next/server";
import { z } from "zod";
const AvatarUpdateSchema = z.object({
userId: z.string().min(1),
- image: z.string().url(),
+ image: z.url(),
});
export async function POST(req: NextRequest) {
const session = await auth();
- if (!session || !session.user) {
- return new Response("Unauthorized", { status: 401 });
- }
+ if (!session?.user) unauthorized();
const body = AvatarUpdateSchema.safeParse(await req.json());
if (!body.success) {
@@ -26,7 +26,7 @@ export async function POST(req: NextRequest) {
if (!user) return new Response("Not found", { status: 404 });
if (user.email !== session.user.email) {
- return new Response("Unauthorized", { status: 401 });
+ unauthorized();
}
await prisma.user.update({
@@ -36,5 +36,14 @@ export async function POST(req: NextRequest) {
Logger.log("new avatar uploaded for user: ", user.email, body.data.image);
+ after(async () => {
+ await auditLog.createAuditLog({
+ userEmail: user.email,
+ action: "USER_AVATAR_UPDATED",
+ target: user.email,
+ details: `Updated avatar for user ${user.email}`,
+ });
+ });
+
return new Response("OK", { status: 200 });
}
diff --git a/src/app/api/user/update-name/route.ts b/src/app/api/user/update-name/route.ts
index 174753c8b..48316c0f5 100644
--- a/src/app/api/user/update-name/route.ts
+++ b/src/app/api/user/update-name/route.ts
@@ -1,6 +1,8 @@
+import { auditLog } from "@/lib/audit-logs";
import { auth } from "@/lib/auth";
import prisma from "@/lib/prisma";
-import { NextRequest } from "next/server";
+import { unauthorized } from "next/navigation";
+import { after, type NextRequest } from "next/server";
import { z } from "zod";
const UpdateNameSchema = z.object({
@@ -18,9 +20,7 @@ const UpdateNameSchema = z.object({
export async function POST(req: NextRequest) {
const session = await auth();
- if (!session || !session.user || !session.user.email) {
- return new Response("Unauthorized", { status: 401 });
- }
+ if (!session || !session.user || !session.user.email) unauthorized();
const body = UpdateNameSchema.safeParse(await req.json());
if (!body.success) {
@@ -32,5 +32,14 @@ export async function POST(req: NextRequest) {
data: { name: body.data.name },
});
+ after(async () => {
+ await auditLog.createAuditLog({
+ userEmail: session.user.email,
+ action: "USER_NAME_UPDATED",
+ target: session.user.email,
+ details: `Updated name for user ${session.user.email} to ${body.data.name}`,
+ });
+ });
+
return new Response("OK", { status: 200 });
}
diff --git a/src/app/contact/layout.tsx b/src/app/contact/layout.tsx
index 49fa3aca0..e316a281c 100644
--- a/src/app/contact/layout.tsx
+++ b/src/app/contact/layout.tsx
@@ -1,11 +1,7 @@
-import Footer from "@/components/footer";
-import Header from "@/components/header";
+import { Footer } from "@/components/footer";
+import { Header } from "@/components/header";
-export default function ContactLayout({
- children,
-}: {
- children: React.ReactNode;
-}) {
+export default function ContactLayout({ children }: LayoutProps<"/contact">) {
return (
<>
diff --git a/src/app/contact/page.tsx b/src/app/contact/page.tsx
index ac325cf7d..badf9e44c 100644
--- a/src/app/contact/page.tsx
+++ b/src/app/contact/page.tsx
@@ -12,9 +12,8 @@ import {
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
-import { toast } from "@/components/ui/use-toast";
import { ClientOnly } from "@/lib/client-only";
-import Logger from "@/lib/logger";
+import { Logger } from "@/lib/logger";
import { EnvelopeIcon, UserGroupIcon } from "@heroicons/react/24/outline";
import { zodResolver } from "@hookform/resolvers/zod";
import { EnvelopeOpenIcon, ExternalLinkIcon } from "@radix-ui/react-icons";
@@ -22,6 +21,7 @@ import { useTranslations } from "next-intl";
import Link from "next/link";
import { useState } from "react";
import { useForm } from "react-hook-form";
+import { toast } from "sonner";
import { z } from "zod";
export default function ContactPage() {
@@ -32,19 +32,15 @@ export default function ContactPage() {
const formSchema = z.object({
name: z
.string({
- required_error: t("contactForm.nameRequiredError"),
+ error: t("contactForm.nameRequiredError"),
})
.min(1),
- email: z
- .string({
- required_error: t("contactForm.emailRequiredError"),
- })
- .email({
- message: t("contactForm.emailMessage"),
- }),
+ email: z.email({
+ message: t("contactForm.emailMessage"),
+ }),
message: z
.string({
- required_error: t("contactForm.messageRequiredError"),
+ error: t("contactForm.messageRequiredError"),
})
.min(1, {
message: t("contactForm.messageMessage"),
@@ -65,27 +61,23 @@ export default function ContactPage() {
});
if (res.ok) {
- toast({
- title: t("contactForm.handleSubmit.title"),
+ toast.success(t("contactForm.handleSubmit.title"), {
description: t("contactForm.handleSubmit.description"),
duration: 5000,
});
form.reset();
} else {
Logger.error("Error sending email", res.statusText);
- toast({
- title: t("contactForm.handleSubmit.errorTitle1"),
+ toast.error(t("contactForm.handleSubmit.errorTitle1"), {
description: t("contactForm.handleSubmit.errorDescription1", {
res: `${await res.text()} (${res.status})`,
}),
duration: 5000,
- variant: "destructive",
});
}
} catch (error) {
Logger.error("Error sending email", error);
- toast({
- title: t("contactForm.handleSubmit.errorTitle2"),
+ toast.error(t("contactForm.handleSubmit.errorTitle2"), {
description: t("contactForm.handleSubmit.errorDescription2"),
duration: 5000,
});
@@ -94,13 +86,13 @@ export default function ContactPage() {
setLoading(false);
}
return (
-
+
-
+
-
+
+ ← {t("back")} +
+
+
+ {scrim?.name ?? t("newScrim")}{" "}
+ {hasPerms && (
+
+
+
+
+
+
+ {t("edit")}
+
+
+
+ )}
+
+
- ← {t("back")} -
-
-
- {scrim?.name ?? t("newScrim")}{" "}
- {hasPerms && (
+
+
+
+
+ {t("maps.title")}
+
+ {maps.length > 0 ? (
+
+ {maps.map((map) => (
+
+
-
-
-
-
-
- {t("edit")}
-
-
+
+
+ {mapNames.get(toKebabCase(map.name)) ?? map.name}
+
+
+
+
+
- )}
-
-
+
+
+
+
+
+
+
+ ))}
+ {hasPerms && }
-
-
-
-
- {t("maps.title")}
-
- {maps.length > 0 ? (
-
- {maps.map((map) => (
-
-
-
-
-
- {mapNames.get(toKebabCase(map.name)) ?? map.name}
-
-
-
-
-
-
-
-
-
-
-
-
-
- ))}
- {hasPerms && }
-
- ) : (
- <>
-
-
- {t("noMaps.title")}
-
- {t("noMaps.description")}
-
- {t("noMaps.link")}
-
- .
-
-
- {hasPerms && }
- >
- )}
-
+ {mapNames.get(toKebabCase(map.name)) ?? map.name} +
+- {mapNames.get(toKebabCase(map.name)) ?? map.name} -
-
+
{t("headerSection.title")}
@@ -200,7 +201,7 @@ export default async function AboutPage() { {/* Content section */}
{t("contentSection.description1")}
{t("contentSection.description2")}
@@ -250,19 +251,19 @@ export default async function AboutPage() { {/* Values section */}
+
{t("featureSection.title")}
{t("featureSection.description")}
-
+
- {value.name} @@ -274,10 +275,10 @@ export default async function AboutPage() {
-
--
+
{t("footerNav.company.title")}
-
{footerNavigation.company.map((item) => (
-+
{t("footerNav.legal.title")}
-
{footerNavigation.legal.map((item) => (
+© 2024–{new Date().getFullYear()}{" "} {t("footerNav.copyright")} diff --git a/src/app/api/(cron)/delete-empty-scrims/route.ts b/src/app/api/(cron)/delete-empty-scrims/route.ts index 066ab5f77..f50ceb585 100644 --- a/src/app/api/(cron)/delete-empty-scrims/route.ts +++ b/src/app/api/(cron)/delete-empty-scrims/route.ts @@ -1,4 +1,4 @@ -import Logger from "@/lib/logger"; +import { Logger } from "@/lib/logger"; import prisma from "@/lib/prisma"; export async function DELETE() { diff --git a/src/app/api/(cron)/delete-unused-blobs/route.ts b/src/app/api/(cron)/delete-unused-blobs/route.ts index 445b511f0..12ebf7056 100644 --- a/src/app/api/(cron)/delete-unused-blobs/route.ts +++ b/src/app/api/(cron)/delete-unused-blobs/route.ts @@ -1,4 +1,4 @@ -import Logger from "@/lib/logger"; +import { Logger } from "@/lib/logger"; import prisma from "@/lib/prisma"; import { del, list } from "@vercel/blob"; diff --git a/src/app/api/(email)/send-contact-form-email/route.ts b/src/app/api/(email)/send-contact-form-email/route.ts index 33a2ea7f5..8ef064f41 100644 --- a/src/app/api/(email)/send-contact-form-email/route.ts +++ b/src/app/api/(email)/send-contact-form-email/route.ts @@ -1,15 +1,16 @@ import ContactFormEmail from "@/components/email/contact-form"; -import { sendEmail } from "@/lib/email"; -import Logger from "@/lib/logger"; +import { email } from "@/lib/email"; +import { Logger } from "@/lib/logger"; import { render } from "@react-email/render"; import { Ratelimit } from "@upstash/ratelimit"; +import { ipAddress } from "@vercel/functions"; import { kv } from "@vercel/kv"; -import { NextRequest } from "next/server"; +import type { NextRequest } from "next/server"; import { z } from "zod"; const ContactFormEmailSchema = z.object({ name: z.string().min(1), - email: z.string().email(), + email: z.email(), message: z.string().min(1), }); @@ -22,7 +23,7 @@ export async function POST(req: NextRequest) { }); // Limit the requests to 5 per minute per user - const identifier = req.ip ?? "127.0.0.1"; + const identifier = ipAddress(req) ?? "127.0.0.1"; const { success } = await ratelimit.limit(identifier); if (!success) { @@ -35,7 +36,7 @@ export async function POST(req: NextRequest) { return new Response("Invalid request", { status: 400 }); } - const emailHtml = render( + const emailHtml = await render( ContactFormEmail({ name: body.data.name, email: body.data.email, @@ -44,7 +45,7 @@ export async function POST(req: NextRequest) { ); try { - await sendEmail({ + await email.sendEmail({ to: "help@parsertime.app", from: "noreply@lux.dev", subject: `New message from ${body.data.name} | Parsertime`, diff --git a/src/app/api/(email)/send-team-invite/route.ts b/src/app/api/(email)/send-team-invite/route.ts index 9604a9fbe..206003f1f 100644 --- a/src/app/api/(email)/send-team-invite/route.ts +++ b/src/app/api/(email)/send-team-invite/route.ts @@ -1,11 +1,15 @@ import TeamInviteUserEmail from "@/components/email/team-invite"; import { getUser } from "@/data/user-dto"; -import { sendEmail } from "@/lib/email"; +import { auditLog } from "@/lib/audit-logs"; +import { email } from "@/lib/email"; import { createShortLink } from "@/lib/link-service"; +import { Logger } from "@/lib/logger"; +import { notifications } from "@/lib/notifications"; import prisma from "@/lib/prisma"; +import { isTaggedError } from "@/lib/utils"; import { render } from "@react-email/render"; import { track } from "@vercel/analytics/server"; -import { NextRequest } from "next/server"; +import { after, type NextRequest } from "next/server"; export async function POST(req: NextRequest) { const inviteeEmail = req.nextUrl.searchParams.get("email"); @@ -40,7 +44,7 @@ export async function POST(req: NextRequest) { `${baseUrl}/team/join/${inviteToken}` ); - const emailHtml = render( + const emailHtml = await render( TeamInviteUserEmail({ username: inviteeEmail, userImage: user.image ?? `https://avatar.vercel.sh/${user.name}.png`, @@ -53,22 +57,75 @@ export async function POST(req: NextRequest) { ); try { - await sendEmail({ + await notifications.createInAppNotification({ + userId: user.id, + title: `You've been invited to join ${team.name} on Parsertime`, + description: `You've been invited to join ${team.name} by ${inviter.name}. Click this notification to accept the invitation.`, + href: `/team/join/${inviteToken}`, + }); + } catch (error) { + if (error && typeof error === "object" && "_tag" in error) { + Logger.error("Error creating in-app notification", error._tag); + } + // fail silently, just log the error as notifications are not critical + } + + try { + await email.sendEmail({ to: inviteeEmail, from: "noreply@lux.dev", subject: `Join ${team.name} on Parsertime`, html: emailHtml, }); } catch (error) { - return new Response("Error sending email", { status: 500 }); - } + if (isTaggedError(error)) { + switch (error._tag) { + case "ValidationError": + return new Response("Invalid email arguments", { status: 400 }); + case "ConfigurationError": + return new Response( + "Configuration error with email service. Please contact support.", + { status: 500 } + ); + case "RateLimitError": + return new Response( + "Rate limit exceeded for sending email. Try again later.", + { status: 429 } + ); + case "EmailSendError": + return new Response( + "Error sending email with email service. Please contact support.", + { status: 500 } + ); + default: + return new Response( + "Unknown error sending email. Please contact support.", + { status: 500 } + ); + } + } - await track("Email Sent", { type: "Team Invite" }); + return new Response( + "Unknown error sending email. Please contact support.", + { status: 500 } + ); + } - await track("Team Invite Sent", { - user: inviter.email, - team: team.name, - invitee: inviteeEmail, + after(async () => { + await Promise.all([ + auditLog.createAuditLog({ + userEmail: inviter.email, + action: "TEAM_INVITE_SENT", + target: inviteeEmail, + details: `Invited ${inviteeEmail} to join ${team.name}`, + }), + track("Team Invite Sent", { + user: inviter.email, + team: team.name, + invitee: inviteeEmail, + }), + track("Email Sent", { type: "Team Invite" }), + ]); }); return new Response("OK", { status: 200 }); diff --git a/src/app/api/admin/audit-logs/route.ts b/src/app/api/admin/audit-logs/route.ts new file mode 100644 index 000000000..5b00c0036 --- /dev/null +++ b/src/app/api/admin/audit-logs/route.ts @@ -0,0 +1,91 @@ +import { getUser } from "@/data/user-dto"; +import { auth } from "@/lib/auth"; +import prisma from "@/lib/prisma"; +import { $Enums, type AuditLogAction, type Prisma } from "@prisma/client"; +import { forbidden, unauthorized } from "next/navigation"; +import { type NextRequest, NextResponse } from "next/server"; + +export async function GET(req: NextRequest) { + const session = await auth(); + if (!session) unauthorized(); + + const user = await getUser(session.user.email); + if (!user) unauthorized(); + if (user.role !== $Enums.UserRole.ADMIN) forbidden(); + + const searchParams = req.nextUrl.searchParams; + const cursor = searchParams.get("cursor"); + const limit = searchParams.get("limit") ?? "10"; + const parsedLimit = parseInt(limit); + const userEmailSearch = searchParams.get("userEmail"); + const targetSearch = searchParams.get("target"); + + const actionParams = searchParams.getAll("action") as AuditLogAction[]; + const startDateParam = searchParams.get("startDate"); + const endDateParam = searchParams.get("endDate"); + + const whereClause: Prisma.AuditLogWhereInput = {}; + + if (actionParams.length > 0) { + if (actionParams.length === 1) { + whereClause.action = actionParams[0]; + } else { + whereClause.action = { in: actionParams }; + } + } + + if (userEmailSearch) { + whereClause.userEmail = { + contains: userEmailSearch, + mode: "insensitive", + }; + } + + if (targetSearch) { + whereClause.target = { + contains: targetSearch, + mode: "insensitive", + }; + } + + const createdAtFilter: Prisma.DateTimeFilter<"AuditLog"> = {}; + if (startDateParam) { + const startDate = new Date(startDateParam); + if (!isNaN(startDate.getTime())) { + createdAtFilter.gte = startDate; + } + } + if (endDateParam) { + const endDate = new Date(endDateParam); + if (!isNaN(endDate.getTime())) { + createdAtFilter.lte = endDate; + } + } + if (Object.keys(createdAtFilter).length > 0) { + whereClause.createdAt = createdAtFilter; + } + + const auditLogs = await prisma.auditLog.findMany({ + where: whereClause, + take: parsedLimit, + ...(cursor + ? { + skip: 1, // Skip the cursor + cursor: { + id: parseInt(cursor), + }, + } + : {}), + orderBy: { + createdAt: "desc", + }, + }); + + const nextCursor = auditLogs[auditLogs.length - 1]?.id; + + return NextResponse.json({ + items: auditLogs, + nextCursor: nextCursor?.toString() ?? undefined, + hasMore: auditLogs.length === parsedLimit, + }); +} diff --git a/src/app/api/admin/impersonate-user/route.ts b/src/app/api/admin/impersonate-user/route.ts index 3ba50453a..6457ca5f2 100644 --- a/src/app/api/admin/impersonate-user/route.ts +++ b/src/app/api/admin/impersonate-user/route.ts @@ -1,34 +1,39 @@ import { getUser } from "@/data/user-dto"; +import { auditLog } from "@/lib/audit-logs"; import { auth, getImpersonateUrl } from "@/lib/auth"; import { $Enums } from "@prisma/client"; -import { NextRequest } from "next/server"; +import { unauthorized } from "next/navigation"; +import { after, type NextRequest } from "next/server"; import { z } from "zod"; const ImpersonateUserSchema = z.object({ - email: z.string().email(), + email: z.email(), isProd: z.boolean(), }); export async function POST(req: NextRequest) { const session = await auth(); - - if (!session || !session.user) { - return new Response("Unauthorized", { status: 401 }); - } + if (!session?.user) unauthorized(); const user = await getUser(session.user.email); - if (!user) return new Response("Unauthorized", { status: 401 }); - - if (user.role !== $Enums.UserRole.ADMIN) { - return new Response("Unauthorized", { status: 401 }); - } + if (!user) unauthorized(); + if (user.role !== $Enums.UserRole.ADMIN) unauthorized(); const body = ImpersonateUserSchema.safeParse(await req.json()); if (!body.success) return new Response("Invalid request", { status: 400 }); const url = await getImpersonateUrl(body.data.email, body.data.isProd); + after(async () => { + await auditLog.createAuditLog({ + userEmail: session.user.email, + action: "IMPERSONATE_USER", + target: body.data.email, + details: `Impersonated ${body.data.email}`, + }); + }); + return new Response(JSON.stringify({ url }), { headers: { "content-type": "application/json" }, }); diff --git a/src/app/api/admin/user-search/route.ts b/src/app/api/admin/user-search/route.ts new file mode 100644 index 000000000..84b9bc90f --- /dev/null +++ b/src/app/api/admin/user-search/route.ts @@ -0,0 +1,94 @@ +import { getUser } from "@/data/user-dto"; +import { auth } from "@/lib/auth"; +import prisma from "@/lib/prisma"; +import type { Prisma } from "@prisma/client"; +import { $Enums } from "@prisma/client"; +import { forbidden, unauthorized } from "next/navigation"; +import { type NextRequest, NextResponse } from "next/server"; + +export async function GET(req: NextRequest) { + const session = await auth(); + if (!session) unauthorized(); + + const user = await getUser(session.user.email); + if (!user) unauthorized(); + if (user.role !== $Enums.UserRole.ADMIN) forbidden(); + + const searchParams = req.nextUrl.searchParams; + const cursor = searchParams.get("cursor"); + const limit = searchParams.get("limit") ?? "10"; + const parsedLimit = parseInt(limit); + const searchQuery = searchParams.get("search"); + const billingPlan = searchParams.get("billingPlan"); + const joinedAfter = searchParams.get("joinedAfter"); + const joinedBefore = searchParams.get("joinedBefore"); + + const whereClause: Prisma.UserWhereInput = { + AND: [ + // Search by username or email + searchQuery + ? { + OR: [ + { + name: { + contains: searchQuery, + mode: "insensitive", + }, + }, + { + email: { + contains: searchQuery, + mode: "insensitive", + }, + }, + ], + } + : {}, + // Filter by billing plan + billingPlan + ? { + billingPlan: billingPlan as $Enums.BillingPlan, + } + : {}, + // Filter by join date range + joinedAfter || joinedBefore + ? { + createdAt: { + ...(joinedAfter && { gte: new Date(joinedAfter) }), + ...(joinedBefore && { lte: new Date(joinedBefore) }), + }, + } + : {}, + ], + }; + + const users = await prisma.user.findMany({ + where: whereClause, + select: { + id: true, + name: true, + email: true, + billingPlan: true, + role: true, + emailVerified: true, + createdAt: true, + }, + take: parsedLimit, + ...(cursor + ? { + skip: 1, // Skip the cursor + cursor: { + id: cursor, + }, + } + : {}), + }); + + const nextCursor = users[users.length - 1]?.id; + + return NextResponse.json({ + items: users, + nextCursor: nextCursor ?? null, + hasMore: users.length === parsedLimit, + }); +} diff --git a/src/app/api/notifications/mark-read/route.ts b/src/app/api/notifications/mark-read/route.ts new file mode 100644 index 000000000..9ab405d92 --- /dev/null +++ b/src/app/api/notifications/mark-read/route.ts @@ -0,0 +1,62 @@ +import { getUser } from "@/data/user-dto"; +import { auth } from "@/lib/auth"; +import { Logger } from "@/lib/logger"; +import { notifications } from "@/lib/notifications"; +import { notFound, unauthorized } from "next/navigation"; +import type { NextRequest } from "next/server"; +import { z } from "zod"; + +const bodySchema = z.object({ + id: z.number().optional(), + all: z.boolean().optional(), +}); + +export async function POST(request: NextRequest) { + const session = await auth(); + if (!session) unauthorized(); + + const user = await getUser(session.user.email); + if (!user) unauthorized(); + + const body = bodySchema.safeParse(await request.json()); + if (!body.success) { + return new Response("Invalid request body", { status: 400 }); + } + const { id, all } = body.data; + + try { + if (all) { + await notifications.markAllAsRead(user.id); + return new Response("OK", { status: 200 }); + } + + if (id) { + await notifications.markAsRead(user.id, id); + return new Response("OK", { status: 200 }); + } + + return new Response( + "An unknown error occurred. Please ensure the request body is valid.", + { + status: 400, + } + ); + } catch (error) { + Logger.error("Error marking notification as read:", error); + + // Handle specific error types if needed + if (error && typeof error === "object" && "_tag" in error) { + if (error._tag === "UnauthorizedError") { + unauthorized(); + } + if (error._tag === "NotFoundError") { + notFound(); + } + if (error._tag === "DatabaseError") { + return new Response("Database error", { status: 500 }); + } + } + + return new Response("Internal server error", { status: 500 }); + } +} diff --git a/src/app/api/notifications/route.ts b/src/app/api/notifications/route.ts new file mode 100644 index 000000000..876bc0b08 --- /dev/null +++ b/src/app/api/notifications/route.ts @@ -0,0 +1,160 @@ +import { getUser } from "@/data/user-dto"; +import { auth } from "@/lib/auth"; +import { Logger } from "@/lib/logger"; +import { notifications } from "@/lib/notifications"; +import { getSession } from "next-auth/react"; +import { notFound, unauthorized } from "next/navigation"; +import type { NextRequest } from "next/server"; +import { z } from "zod"; + +const createNotificationSchema = z.object({ + title: z.string().min(1), + description: z.string().min(1), + href: z.string().optional(), + userId: z.string().optional(), +}); + +export async function GET(request: NextRequest) { + const session = await auth(); + if (!session) unauthorized(); + + const user = await getUser(session.user.email); + if (!user) unauthorized(); + + const searchParams = request.nextUrl.searchParams; + const page = parseInt(searchParams.get("page") ?? "1"); + const limit = parseInt(searchParams.get("limit") ?? "10"); + + try { + const result = await notifications.getUserNotificationsPaginated( + user.id, + page, + limit + ); + + return new Response( + JSON.stringify({ + notifications: result.notifications, + pagination: result.pagination, + }) + ); + } catch (error) { + Logger.error("Error fetching notifications:", error); + + // Handle specific error types if needed + if (error && typeof error === "object" && "_tag" in error) { + if (error._tag === "UnauthorizedError") { + unauthorized(); + } + if (error._tag === "NotFoundError") { + notFound(); + } + if (error._tag === "DatabaseError") { + return new Response("Database error", { status: 500 }); + } + } + + return new Response("Internal server error", { status: 500 }); + } +} + +export async function POST(request: NextRequest) { + const session = await getSession(); + if (!session) unauthorized(); + + try { + const body = createNotificationSchema.safeParse(await request.json()); + if (!body.success) { + return new Response("Invalid request body", { status: 400 }); + } + + // Determine the target user ID + let targetUserId: string; + + if (body.data.userId) { + // Explicit user ID provided - verify session user has permission + const sessionUser = await getUser(session.user.email); + if (!sessionUser) unauthorized(); + + // For now, allow any authenticated user to create notifications for any user + // TODO: Add role-based checks here + targetUserId = body.data.userId; + } else { + // Fallback to session user (backward compatibility) + const sessionUser = await getUser(session.user.email); + if (!sessionUser) unauthorized(); + targetUserId = sessionUser.id; + } + + const notification = await notifications.createInAppNotification({ + title: body.data.title, + description: body.data.description, + href: body.data.href, + userId: targetUserId, + }); + + return new Response(JSON.stringify(notification), { + status: 201, + headers: { + "Content-Type": "application/json", + }, + }); + } catch (error) { + Logger.error("Error creating notification:", error); + + // Handle specific error types if needed + if (error && typeof error === "object" && "_tag" in error) { + if (error._tag === "ValidationError") { + return new Response("Validation error", { status: 400 }); + } + if (error._tag === "UserNotFoundError") { + return new Response("User not found", { status: 404 }); + } + if (error._tag === "UnauthorizedError") { + unauthorized(); + } + if (error._tag === "NotFoundError") { + notFound(); + } + if (error._tag === "DatabaseError") { + return new Response("Database error", { status: 500 }); + } + } + + return new Response("Internal server error", { status: 500 }); + } +} + +export async function DELETE(request: NextRequest) { + const session = await auth(); + if (!session) unauthorized(); + + const user = await getUser(session.user.email); + if (!user) unauthorized(); + + const searchParams = request.nextUrl.searchParams; + const id = searchParams.get("id"); + if (!id) return new Response("No ID provided", { status: 400 }); + + try { + await notifications.deleteNotification(user.id, parseInt(id)); + return new Response("OK", { status: 200 }); + } catch (error) { + Logger.error("Error deleting notification:", error); + + // Handle specific error types if needed + if (error && typeof error === "object" && "_tag" in error) { + if (error._tag === "UnauthorizedError") { + unauthorized(); + } + if (error._tag === "NotFoundError") { + notFound(); + } + if (error._tag === "DatabaseError") { + return new Response("Database error", { status: 500 }); + } + } + + return new Response("Internal server error", { status: 500 }); + } +} diff --git a/src/app/api/og/route.tsx b/src/app/api/og/route.tsx index d3f3c920b..0cbf7c977 100644 --- a/src/app/api/og/route.tsx +++ b/src/app/api/og/route.tsx @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/require-await */ /* eslint-disable @next/next/no-img-element */ -import Logger from "@/lib/logger"; +import { Logger } from "@/lib/logger"; import { ImageResponse } from "next/og"; export const runtime = "edge"; diff --git a/src/app/api/reporting/submit-bug-report/route.ts b/src/app/api/reporting/submit-bug-report/route.ts index ca9c9e727..62eea3f92 100644 --- a/src/app/api/reporting/submit-bug-report/route.ts +++ b/src/app/api/reporting/submit-bug-report/route.ts @@ -1,15 +1,16 @@ import { getUser } from "@/data/user-dto"; +import { auditLog } from "@/lib/audit-logs"; import { newBugReportWebhookConstructor, sendDiscordWebhook, } from "@/lib/webhooks"; -import { NextRequest, userAgent } from "next/server"; +import { after, type NextRequest, userAgent } from "next/server"; import { z } from "zod"; const BugReportSchema = z.object({ title: z.string().min(1), description: z.string().min(1), - email: z.string().email(), + email: z.email(), url: z.string().min(1), }); @@ -35,5 +36,14 @@ export async function POST(req: NextRequest) { await sendDiscordWebhook(process.env.BUG_REPORT_WEBHOOK_URL, wh); + after(async () => { + await auditLog.createAuditLog({ + userEmail: user?.email ?? "Unknown", + action: "BUG_REPORT_SUBMITTED", + target: body.data.title, + details: `Bug report submitted: ${body.data.title}`, + }); + }); + return new Response("OK", { status: 200 }); } diff --git a/src/app/api/scrim/add-map/route.ts b/src/app/api/scrim/add-map/route.ts index 88d83cb5e..2c29295af 100644 --- a/src/app/api/scrim/add-map/route.ts +++ b/src/app/api/scrim/add-map/route.ts @@ -1,9 +1,11 @@ +import { auditLog } from "@/lib/audit-logs"; import { auth } from "@/lib/auth"; -import Logger from "@/lib/logger"; +import { Logger } from "@/lib/logger"; import { createNewMap } from "@/lib/parser"; -import { ParserData } from "@/types/parser"; +import type { ParserData } from "@/types/parser"; import { track } from "@vercel/analytics/server"; -import { NextRequest } from "next/server"; +import { unauthorized } from "next/navigation"; +import { after, type NextRequest } from "next/server"; export async function POST(req: NextRequest) { const session = await auth(); @@ -14,13 +16,23 @@ export async function POST(req: NextRequest) { if (!session || !session.user || !session.user.email) { Logger.warn("Unauthorized request to add map"); - return new Response("Unauthorized", { status: 401 }); + unauthorized(); } try { await createNewMap({ map: data, scrimId: parseInt(id) }, session); - await track("Create Map", { user: session.user.email }); + after(async () => { + await Promise.all([ + track("Create Map", { user: session.user.email }), + auditLog.createAuditLog({ + userEmail: session.user.email, + action: "MAP_CREATED", + target: id, + details: `Map created: ${id}`, + }), + ]); + }); return new Response("OK", { status: 200 }); } catch (e: unknown) { diff --git a/src/app/api/scrim/create-scrim/route.ts b/src/app/api/scrim/create-scrim/route.ts index 5d226471f..970ad7fc9 100644 --- a/src/app/api/scrim/create-scrim/route.ts +++ b/src/app/api/scrim/create-scrim/route.ts @@ -1,16 +1,19 @@ import { getUser } from "@/data/user-dto"; +import { auditLog } from "@/lib/audit-logs"; import { auth } from "@/lib/auth"; -import Logger from "@/lib/logger"; +import { Logger } from "@/lib/logger"; import { createNewScrimFromParsedData } from "@/lib/parser"; import { newSuspiciousActivityWebhookConstructor, sendDiscordWebhook, } from "@/lib/webhooks"; -import { ParserData } from "@/types/parser"; -import { User } from "@prisma/client"; +import type { ParserData } from "@/types/parser"; +import type { User } from "@prisma/client"; import { Ratelimit } from "@upstash/ratelimit"; +import { ipAddress } from "@vercel/functions"; import { kv } from "@vercel/kv"; -import { NextRequest, userAgent } from "next/server"; +import { unauthorized } from "next/navigation"; +import { after, type NextRequest, userAgent } from "next/server"; export type CreateScrimRequestData = { name: string; @@ -25,7 +28,7 @@ export async function POST(request: NextRequest) { if (!session) { Logger.warn("Unauthorized request to create scrim"); - return new Response("Unauthorized", { status: 401 }); + unauthorized(); } // Create a new ratelimiter, that allows 5 requests per 1 minute @@ -36,7 +39,7 @@ export async function POST(request: NextRequest) { }); // Limit the requests to 5 per minute per user - const identifier = request.ip ?? "127.0.0.1"; + const identifier = ipAddress(request) ?? "127.0.0.1"; const { success } = await ratelimit.limit(identifier); if (!success) { @@ -46,6 +49,7 @@ export async function POST(request: NextRequest) { const user = await getUser(session.user.email); + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions const fallbackUser = { name: "Unknown", email: "unknown", @@ -62,6 +66,15 @@ export async function POST(request: NextRequest) { ); await sendDiscordWebhook(process.env.DISCORD_WEBHOOK_URL, wh); + after(async () => { + await auditLog.createAuditLog({ + userEmail: "System", + action: "SUSPICIOUS_ACTIVITY_DETECTED", + target: `${user?.email ?? "Unknown"}`, + details: `Scrim creation (rate limit exceeded) by ${user?.name} (${user?.email})`, + }); + }); + return new Response("Rate limit exceeded", { status: 429 }); } @@ -76,5 +89,14 @@ export async function POST(request: NextRequest) { await createNewScrimFromParsedData(data, session); + after(async () => { + await auditLog.createAuditLog({ + userEmail: session.user.email, + action: "SCRIM_CREATED", + target: `${data.name} (Team: ${data.team})`, + details: `Scrim created: ${data.name}`, + }); + }); + return new Response("OK", { status: 200 }); } diff --git a/src/app/api/scrim/remove-map/route.ts b/src/app/api/scrim/remove-map/route.ts index c19646f1e..731e0d665 100644 --- a/src/app/api/scrim/remove-map/route.ts +++ b/src/app/api/scrim/remove-map/route.ts @@ -1,10 +1,12 @@ import { getScrim } from "@/data/scrim-dto"; import { getUser } from "@/data/user-dto"; +import { auditLog } from "@/lib/audit-logs"; import { auth } from "@/lib/auth"; -import Logger from "@/lib/logger"; +import { Logger } from "@/lib/logger"; import prisma from "@/lib/prisma"; import { $Enums } from "@prisma/client"; -import { NextRequest } from "next/server"; +import { unauthorized } from "next/navigation"; +import { after, type NextRequest } from "next/server"; export async function POST(req: NextRequest) { const params = req.nextUrl.searchParams; @@ -16,7 +18,7 @@ export async function POST(req: NextRequest) { if (!session) { if (token !== process.env.DEV_TOKEN) { Logger.warn("Unauthorized request to remove map: ", id); - return new Response("Unauthorized", { status: 401 }); + unauthorized(); } Logger.log("Authorized removal of map with dev token"); } @@ -36,7 +38,7 @@ export async function POST(req: NextRequest) { let isManager = false; - if (scrim.teamId !== 0) { + if (scrim.teamId !== 0 && scrim.teamId !== null) { // scrim is associated with a team const managers = await prisma.team.findFirst({ where: { id: scrim.teamId ?? 0 }, @@ -55,7 +57,7 @@ export async function POST(req: NextRequest) { isManager; // Managers of the scrim's team can delete the map if (!hasPerms) { - return new Response("Unauthorized", { status: 401 }); + unauthorized(); } Logger.log("Removing map: ", id); @@ -90,5 +92,14 @@ export async function POST(req: NextRequest) { prisma.ultimateStart.deleteMany({ where: { MapDataId: mapId } }), ]); + after(async () => { + await auditLog.createAuditLog({ + userEmail: user.email, + action: "MAP_DELETED", + target: `${scrim.name} (ID: ${scrim.id})`, + details: `Map deleted: ${id}`, + }); + }); + return new Response("OK", { status: 200 }); } diff --git a/src/app/api/scrim/remove-scrim/route.ts b/src/app/api/scrim/remove-scrim/route.ts index d6e13e06e..71dc9c258 100644 --- a/src/app/api/scrim/remove-scrim/route.ts +++ b/src/app/api/scrim/remove-scrim/route.ts @@ -1,10 +1,13 @@ import { getScrim } from "@/data/scrim-dto"; import { getUser } from "@/data/user-dto"; +import { auditLog } from "@/lib/audit-logs"; import { auth } from "@/lib/auth"; -import Logger from "@/lib/logger"; +import { Logger } from "@/lib/logger"; +import { notifications } from "@/lib/notifications"; import prisma from "@/lib/prisma"; import { $Enums } from "@prisma/client"; -import { NextRequest } from "next/server"; +import { unauthorized } from "next/navigation"; +import { after, type NextRequest } from "next/server"; export async function POST(req: NextRequest) { const params = req.nextUrl.searchParams; @@ -16,7 +19,7 @@ export async function POST(req: NextRequest) { if (!session) { if (token !== process.env.DEV_TOKEN) { Logger.warn("Unauthorized request to remove scrim: ", id); - return new Response("Unauthorized", { status: 401 }); + unauthorized(); } Logger.log("Authorized removal of scrim with dev token"); } @@ -50,10 +53,30 @@ export async function POST(req: NextRequest) { user.id === scrim.creatorId || // Creators can delete their own scrims isManager; // Managers of the scrim's team can delete the scrim - if (!hasPerms) return new Response("Unauthorized", { status: 401 }); + if (!hasPerms) unauthorized(); const scrimId = parseInt(id); + // If not an individual scrim, create a notification for the team + if (scrim.teamId !== 0 && scrim.teamId !== null) { + const team = await prisma.team.findFirst({ + where: { id: scrim.teamId ?? 0 }, + }); + if (!team) return new Response("Team not found", { status: 404 }); + + const teamMembers = await prisma.user.findMany({ + where: { teams: { some: { id: scrim.teamId ?? 0 } } }, + }); + + for (const member of teamMembers) { + await notifications.createInAppNotification({ + userId: member.id, + title: `${team.name}: Scrim ${scrim.name} has been deleted`, + description: `Scrim "${scrim.name}" has been deleted by ${user.name}.`, + }); + } + } + await Promise.all([ prisma.scrim.delete({ where: { id: scrimId } }), prisma.map.deleteMany({ where: { scrimId } }), @@ -83,5 +106,14 @@ export async function POST(req: NextRequest) { prisma.ultimateStart.deleteMany({ where: { scrimId } }), ]); + after(async () => { + await auditLog.createAuditLog({ + userEmail: user.email, + action: "SCRIM_DELETED", + target: `${scrim.name} (Team: ${scrim.teamId ?? "Individual"})`, + details: `Scrim deleted: ${id}`, + }); + }); + return new Response("OK", { status: 200 }); } diff --git a/src/app/api/scrim/update-scrim-options/route.ts b/src/app/api/scrim/update-scrim-options/route.ts index c07ebebd0..16ddd335d 100644 --- a/src/app/api/scrim/update-scrim-options/route.ts +++ b/src/app/api/scrim/update-scrim-options/route.ts @@ -1,16 +1,18 @@ import { getScrim } from "@/data/scrim-dto"; import { getUser } from "@/data/user-dto"; +import { auditLog } from "@/lib/audit-logs"; import { auth } from "@/lib/auth"; import prisma from "@/lib/prisma"; import { $Enums } from "@prisma/client"; -import { NextRequest } from "next/server"; +import { unauthorized } from "next/navigation"; +import { after, type NextRequest } from "next/server"; import { z } from "zod"; const UpdateScrimSchema = z.object({ name: z.string().min(1).max(30), teamId: z.string().min(1), scrimId: z.number(), - date: z.string().datetime(), + date: z.iso.datetime(), guestMode: z.boolean(), maps: z.array( z.object({ @@ -23,14 +25,14 @@ const UpdateScrimSchema = z.object({ export async function POST(req: NextRequest) { const session = await auth(); if (!session || !session.user || !session.user.email) { - return new Response("Unauthorized", { status: 401 }); + unauthorized(); } const body = UpdateScrimSchema.safeParse(await req.json()); if (!body.success) return new Response("Invalid request", { status: 400 }); const user = await getUser(session.user.email); - if (!user) return new Response("Unauthorized", { status: 401 }); + if (!user) unauthorized(); const userIsManager = await prisma.teamManager.findFirst({ where: { userId: user.id }, @@ -45,7 +47,7 @@ export async function POST(req: NextRequest) { user.role === $Enums.UserRole.ADMIN || // user is an admin user.role === $Enums.UserRole.MANAGER; // user is a manager - if (!hasPerms) return new Response("Unauthorized", { status: 401 }); + if (!hasPerms) unauthorized(); await prisma.scrim.update({ where: { id: body.data.scrimId }, @@ -66,5 +68,14 @@ export async function POST(req: NextRequest) { } } + after(async () => { + await auditLog.createAuditLog({ + userEmail: user.email, + action: "SCRIM_UPDATED", + target: `${scrim.name} (ID: ${scrim.id})`, + details: `Scrim updated: ${scrim.name} (ID: ${scrim.id})`, + }); + }); + return new Response("OK", { status: 200 }); } diff --git a/src/app/api/stripe/webhooks/route.ts b/src/app/api/stripe/webhooks/route.ts index 45a47452f..3d46ad8a4 100644 --- a/src/app/api/stripe/webhooks/route.ts +++ b/src/app/api/stripe/webhooks/route.ts @@ -1,9 +1,8 @@ -/* eslint-disable no-case-declarations */ import { handleSubscriptionEvent } from "@/lib/billing-plans"; -import Logger from "@/lib/logger"; +import { Logger } from "@/lib/logger"; import { stripe } from "@/lib/stripe"; import { track } from "@vercel/analytics/server"; -import Stripe from "stripe"; +import type Stripe from "stripe"; const relevantEvents = new Set([ "customer.created", @@ -16,7 +15,7 @@ const relevantEvents = new Set([ export async function POST(req: Request) { const body = await req.text(); - const sig = req.headers.get("stripe-signature") as string; + const sig = req.headers.get("stripe-signature")!; const webhookSecret = process.env.NODE_ENV === "production" ? process.env.STRIPE_WEBHOOK_SECRET @@ -39,11 +38,12 @@ export async function POST(req: Request) { if (relevantEvents.has(event.type)) { try { switch (event.type) { - case "customer.created": + case "customer.created": { const customer = event.data.object; Logger.log("New customer", customer); await track("New Customer", { customerId: customer.id }); break; + } case "customer.deleted": Logger.log("Deleted customer", event.data.object); await track("Deleted Customer", { customerId: event.data.object.id }); @@ -53,7 +53,7 @@ export async function POST(req: Request) { case "customer.subscription.deleted": await handleSubscriptionEvent(event, event.type); break; - case "checkout.session.completed": + case "checkout.session.completed": { const checkoutSession = event.data.object; if (checkoutSession.mode === "subscription") { const subscriptionId = checkoutSession.subscription; @@ -64,6 +64,7 @@ export async function POST(req: Request) { ); } break; + } default: throw new Error("Unhandled relevant event!"); } diff --git a/src/app/api/team/avatar-upload/route.ts b/src/app/api/team/avatar-upload/route.ts index d62ff787d..4ceb26a96 100644 --- a/src/app/api/team/avatar-upload/route.ts +++ b/src/app/api/team/avatar-upload/route.ts @@ -1,18 +1,17 @@ import { getUser } from "@/data/user-dto"; import { auth } from "@/lib/auth"; -import Logger from "@/lib/logger"; +import { Logger } from "@/lib/logger"; import prisma from "@/lib/prisma"; import { Ratelimit } from "@upstash/ratelimit"; import { track } from "@vercel/analytics/server"; import { handleUpload, type HandleUploadBody } from "@vercel/blob/client"; import { kv } from "@vercel/kv"; -import { NextRequest, NextResponse } from "next/server"; +import { unauthorized } from "next/navigation"; +import { type NextRequest, NextResponse } from "next/server"; export async function POST(request: NextRequest): Promise
{ const session = await auth(); - if (!session || !session.user) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } + if (!session?.user) unauthorized(); const userId = await getUser(session.user.email); if (!userId) { @@ -48,20 +47,19 @@ export async function POST(request: NextRequest): Promise { const jsonResponse = await handleUpload({ body, request, - onBeforeGenerateToken: async ( - pathname: string + onBeforeGenerateToken: async () => /* clientPayload?: string, */ - ) => { - // Generate a client token for the browser to upload the file - // ⚠️ Authenticate and authorize users before generating the token. - // Otherwise, you're allowing anonymous uploads. - const team = await prisma.team.findUnique({ - where: { id: parseInt(teamId) }, - }); - if (!team) throw new Error("Team not found"); + { + // Generate a client token for the browser to upload the file + // ⚠️ Authenticate and authorize users before generating the token. + // Otherwise, you're allowing anonymous uploads. + const team = await prisma.team.findUnique({ + where: { id: parseInt(teamId) }, + }); + if (!team) throw new Error("Team not found"); - return { tokenPayload: JSON.stringify({ teamId: team.id }) }; - }, + return { tokenPayload: JSON.stringify({ teamId: team.id }) }; + }, onUploadCompleted: async ({ blob, tokenPayload }) => { // Get notified of client upload completion // ⚠️ This will not work on `localhost` websites, @@ -83,7 +81,7 @@ export async function POST(request: NextRequest): Promise { where: { id: team.id }, data: { image: blob.url }, }); - } catch (error) { + } catch { throw new Error("Could not update team"); } }, diff --git a/src/app/api/team/create-team-invite/route.ts b/src/app/api/team/create-team-invite/route.ts index 0739d6a98..ff06e243d 100644 --- a/src/app/api/team/create-team-invite/route.ts +++ b/src/app/api/team/create-team-invite/route.ts @@ -1,8 +1,9 @@ import { auth } from "@/lib/auth"; import { generateRandomToken } from "@/lib/invite-token"; -import Logger from "@/lib/logger"; +import { Logger } from "@/lib/logger"; import prisma from "@/lib/prisma"; -import { NextRequest } from "next/server"; +import { unauthorized } from "next/navigation"; +import type { NextRequest } from "next/server"; const FREE_MEMBER_CAP = 5; const BASIC_MEMBER_CAP = 10; @@ -17,13 +18,13 @@ export async function POST(req: NextRequest) { if (!session) { if (token !== process.env.DEV_TOKEN) { Logger.warn("Unauthorized request to create team invite"); - return new Response("Unauthorized", { status: 401 }); + unauthorized(); } Logger.log("Authorized request to create team invite using dev token"); } const teamId = req.nextUrl.searchParams.get("id") - ? parseInt(req.nextUrl.searchParams.get("id") as string) + ? parseInt(req.nextUrl.searchParams.get("id")!) : null; if (!teamId) { @@ -78,8 +79,7 @@ export async function POST(req: NextRequest) { } break; default: - if (teamCreator.role !== "ADMIN") - return new Response("Unauthorized", { status: 401 }); + if (teamCreator.role !== "ADMIN") unauthorized(); break; } diff --git a/src/app/api/team/create-team/route.ts b/src/app/api/team/create-team/route.ts index 8d0efafe1..02557b045 100644 --- a/src/app/api/team/create-team/route.ts +++ b/src/app/api/team/create-team/route.ts @@ -1,11 +1,13 @@ import { getUser } from "@/data/user-dto"; +import { auditLog } from "@/lib/audit-logs"; import { auth } from "@/lib/auth"; -import Logger from "@/lib/logger"; +import { Logger } from "@/lib/logger"; import { Permission } from "@/lib/permissions"; import prisma from "@/lib/prisma"; import { Ratelimit } from "@upstash/ratelimit"; +import { ipAddress } from "@vercel/functions"; import { kv } from "@vercel/kv"; -import { NextRequest } from "next/server"; +import { after, type NextRequest } from "next/server"; import { z } from "zod"; const TeamCreationRequestSchema = z.object({ @@ -21,7 +23,7 @@ export async function POST(request: NextRequest) { }); // Limit the requests to 5 per minute per user - const identifier = request.ip ?? "127.0.0.1"; + const identifier = ipAddress(request) ?? "127.0.0.1"; const { success } = await ratelimit.limit(identifier); if (!success) { @@ -107,5 +109,14 @@ export async function POST(request: NextRequest) { data: { teams: { connect: [{ id: team.id }] } }, }); + after(async () => { + await auditLog.createAuditLog({ + userEmail: session.user.email, + action: "TEAM_CREATED", + target: team.name, + details: `Team created: ${team.name}`, + }); + }); + return new Response("Success", { status: 200 }); } diff --git a/src/app/api/team/delete-expired-tokens/route.ts b/src/app/api/team/delete-expired-tokens/route.ts index 1f485a117..c48389890 100644 --- a/src/app/api/team/delete-expired-tokens/route.ts +++ b/src/app/api/team/delete-expired-tokens/route.ts @@ -1,4 +1,4 @@ -import Logger from "@/lib/logger"; +import { Logger } from "@/lib/logger"; import prisma from "@/lib/prisma"; export async function DELETE() { diff --git a/src/app/api/team/demote-user/route.ts b/src/app/api/team/demote-user/route.ts index 1086a71f4..853bb2c2e 100644 --- a/src/app/api/team/demote-user/route.ts +++ b/src/app/api/team/demote-user/route.ts @@ -1,8 +1,10 @@ import { getUser } from "@/data/user-dto"; +import { auditLog } from "@/lib/audit-logs"; import { auth } from "@/lib/auth"; -import Logger from "@/lib/logger"; +import { Logger } from "@/lib/logger"; import prisma from "@/lib/prisma"; -import { NextRequest } from "next/server"; +import { unauthorized } from "next/navigation"; +import { after, type NextRequest } from "next/server"; import { z } from "zod"; const DemoteUserSchema = z.object({ @@ -16,7 +18,7 @@ export async function POST(req: NextRequest) { const devTokenAuthed = authToken === process.env.DEV_TOKEN; if (!session || !session.user || !session.user.email) { - if (!devTokenAuthed) return new Response("Unauthorized", { status: 401 }); + if (!devTokenAuthed) unauthorized(); Logger.log("Authorized with dev token"); } @@ -33,9 +35,7 @@ export async function POST(req: NextRequest) { const authedUser = await getUser(session?.user?.email); - if (!authedUser && !devTokenAuthed) { - return new Response("Unauthorized", { status: 401 }); - } + if (!authedUser && !devTokenAuthed) unauthorized(); if (!devTokenAuthed) { const managers = await prisma.team.findFirst({ @@ -47,9 +47,7 @@ export async function POST(req: NextRequest) { (manager) => manager.userId === authedUser?.id ); - if (team.ownerId !== authedUser?.id && !isManager) { - return new Response("Unauthorized", { status: 401 }); - } + if (team.ownerId !== authedUser?.id && !isManager) unauthorized(); } // demote user to member @@ -57,5 +55,14 @@ export async function POST(req: NextRequest) { where: { userId: user.id, teamId: team.id }, }); + after(async () => { + await auditLog.createAuditLog({ + userEmail: authedUser!.email, + action: "TEAM_MEMBER_DEMOTED", + target: team.name, + details: `Demoted ${user.name} to member`, + }); + }); + return new Response("OK", { status: 200 }); } diff --git a/src/app/api/team/get-teams-with-perms/route.ts b/src/app/api/team/get-teams-with-perms/route.ts index dcc3d6b0c..9f30bfb92 100644 --- a/src/app/api/team/get-teams-with-perms/route.ts +++ b/src/app/api/team/get-teams-with-perms/route.ts @@ -1,15 +1,16 @@ import { getUser } from "@/data/user-dto"; import { auth } from "@/lib/auth"; -import Logger from "@/lib/logger"; +import { Logger } from "@/lib/logger"; import prisma from "@/lib/prisma"; import { $Enums } from "@prisma/client"; +import { unauthorized } from "next/navigation"; export async function GET() { const session = await auth(); if (!session) { Logger.warn("Unauthorized request to get teams API"); - return new Response("Unauthorized", { status: 401 }); + unauthorized(); } const userId = await getUser(session?.user?.email); diff --git a/src/app/api/team/get-teams/route.ts b/src/app/api/team/get-teams/route.ts index 931faeb9b..990da32f8 100644 --- a/src/app/api/team/get-teams/route.ts +++ b/src/app/api/team/get-teams/route.ts @@ -1,8 +1,9 @@ import { getUser } from "@/data/user-dto"; import { auth } from "@/lib/auth"; -import Logger from "@/lib/logger"; +import { Logger } from "@/lib/logger"; import prisma from "@/lib/prisma"; -import { Team } from "@prisma/client"; +import type { Team } from "@prisma/client"; +import { unauthorized } from "next/navigation"; export type GetTeamsResponse = { teams: Team[]; @@ -13,7 +14,7 @@ export async function GET() { if (!session) { Logger.warn("Unauthorized request to get teams API"); - return new Response("Unauthorized", { status: 401 }); + unauthorized(); } const userId = await getUser(session?.user?.email); diff --git a/src/app/api/team/join-team/route.ts b/src/app/api/team/join-team/route.ts index a9d79acd7..7beb433b6 100644 --- a/src/app/api/team/join-team/route.ts +++ b/src/app/api/team/join-team/route.ts @@ -1,7 +1,9 @@ +import { auditLog } from "@/lib/audit-logs"; import { auth } from "@/lib/auth"; -import Logger from "@/lib/logger"; +import { Logger } from "@/lib/logger"; import prisma from "@/lib/prisma"; -import { NextRequest } from "next/server"; +import { unauthorized } from "next/navigation"; +import { after, type NextRequest } from "next/server"; export async function POST(req: NextRequest) { const session = await auth(); @@ -12,7 +14,7 @@ export async function POST(req: NextRequest) { if (!session) { if (authToken !== process.env.DEV_TOKEN) { Logger.warn("Unauthorized request to create team invite"); - return new Response("Unauthorized", { status: 401 }); + unauthorized(); } Logger.log("Authorized request to create team invite using dev token"); } @@ -54,5 +56,14 @@ export async function POST(req: NextRequest) { Logger.log(`User now belongs to team: ${JSON.stringify(teams)}`); + after(async () => { + await auditLog.createAuditLog({ + userEmail: session?.user?.email ?? testingEmail, + action: "TEAM_JOINED", + target: `${teams[0].name}`, + details: `Joined team ${teams[0].name} (Team ID: ${teamInviteToken.teamId})`, + }); + }); + return new Response("OK", { status: 200 }); } diff --git a/src/app/api/team/promote-user/route.ts b/src/app/api/team/promote-user/route.ts index 5f30051f4..4b307cb0f 100644 --- a/src/app/api/team/promote-user/route.ts +++ b/src/app/api/team/promote-user/route.ts @@ -1,8 +1,10 @@ import { getUser } from "@/data/user-dto"; +import { auditLog } from "@/lib/audit-logs"; import { auth } from "@/lib/auth"; -import Logger from "@/lib/logger"; +import { Logger } from "@/lib/logger"; import prisma from "@/lib/prisma"; -import { NextRequest } from "next/server"; +import { unauthorized } from "next/navigation"; +import { after, type NextRequest } from "next/server"; import { z } from "zod"; const PromoteUserSchema = z.object({ @@ -16,9 +18,8 @@ export async function POST(req: NextRequest) { const devTokenAuthed = authToken === process.env.DEV_TOKEN; if (!session || !session.user || !session.user.email) { - if (!devTokenAuthed) { - return new Response("Unauthorized", { status: 401 }); - } + if (!devTokenAuthed) unauthorized(); + Logger.log("Authorized with dev token"); } @@ -53,9 +54,7 @@ export async function POST(req: NextRequest) { (manager) => manager.userId === authedUser?.id ); - if (team.ownerId !== authedUser?.id && !isManager) { - return new Response("Unauthorized", { status: 401 }); - } + if (team.ownerId !== authedUser?.id && !isManager) unauthorized(); } // add user as a manager @@ -63,5 +62,14 @@ export async function POST(req: NextRequest) { data: { userId, teamId: parseInt(teamId) }, }); + after(async () => { + await auditLog.createAuditLog({ + userEmail: authedUser!.email, + action: "TEAM_MEMBER_PROMOTED", + target: user.email, + details: `Promoted ${user.name ?? user.email} to manager of team ${team.name}`, + }); + }); + return new Response("OK", { status: 200 }); } diff --git a/src/app/api/team/remove-team/route.ts b/src/app/api/team/remove-team/route.ts index 9fbc43c44..a81909d4b 100644 --- a/src/app/api/team/remove-team/route.ts +++ b/src/app/api/team/remove-team/route.ts @@ -1,9 +1,11 @@ import { getUser } from "@/data/user-dto"; +import { auditLog } from "@/lib/audit-logs"; import { auth } from "@/lib/auth"; -import Logger from "@/lib/logger"; +import { Logger } from "@/lib/logger"; import prisma from "@/lib/prisma"; import { $Enums } from "@prisma/client"; -import { NextRequest } from "next/server"; +import { unauthorized } from "next/navigation"; +import { after, type NextRequest } from "next/server"; export async function POST(req: NextRequest) { const params = req.nextUrl.searchParams; @@ -18,7 +20,7 @@ export async function POST(req: NextRequest) { if (!session) { if (token !== process.env.DEV_TOKEN) { Logger.warn("Unauthorized request to remove team: ", id); - return new Response("Unauthorized", { status: 401 }); + unauthorized(); } Logger.log("Authorized removal of team with dev token"); } @@ -34,12 +36,19 @@ export async function POST(req: NextRequest) { user.role === $Enums.UserRole.MANAGER || // Managers can delete anything user.id === team.ownerId; // Creators can delete their own teams - if (!hasPerms) { - return new Response("Unauthorized", { status: 401 }); - } + if (!hasPerms) unauthorized(); await prisma.team.delete({ where: { id } }); await prisma.teamManager.deleteMany({ where: { teamId: id } }); + after(async () => { + await auditLog.createAuditLog({ + userEmail: user.email, + action: "TEAM_DELETED", + target: team.name, + details: `Team deleted: ${team.name}`, + }); + }); + return new Response("OK", { status: 200 }); } diff --git a/src/app/api/team/remove-user-from-team/route.ts b/src/app/api/team/remove-user-from-team/route.ts index 0b4bf8319..62fec79cd 100644 --- a/src/app/api/team/remove-user-from-team/route.ts +++ b/src/app/api/team/remove-user-from-team/route.ts @@ -1,8 +1,10 @@ import { getUser } from "@/data/user-dto"; +import { auditLog } from "@/lib/audit-logs"; import { auth } from "@/lib/auth"; import prisma from "@/lib/prisma"; -import { User } from "@prisma/client"; -import { NextRequest } from "next/server"; +import type { User } from "@prisma/client"; +import { unauthorized } from "next/navigation"; +import { after, type NextRequest } from "next/server"; import { z } from "zod"; const RemoveUserFromTeamSchema = z.object({ @@ -12,9 +14,7 @@ const RemoveUserFromTeamSchema = z.object({ export async function POST(req: NextRequest) { const session = await auth(); - if (!session || !session.user || !session.user.email) { - return new Response("Unauthorized", { status: 401 }); - } + if (!session || !session.user || !session.user.email) unauthorized(); const body = RemoveUserFromTeamSchema.safeParse(await req.json()); if (!body.success) return new Response("Invalid request", { status: 400 }); @@ -28,7 +28,7 @@ export async function POST(req: NextRequest) { if (!user) return new Response("User not found", { status: 404 }); const authedUser = await getUser(session.user.email); - if (!authedUser) return new Response("Unauthorized", { status: 401 }); + if (!authedUser) unauthorized(); const managers = await prisma.team.findFirst({ where: { id: parseInt(body.data.teamId) }, @@ -44,7 +44,7 @@ export async function POST(req: NextRequest) { } if (team.ownerId !== authedUser.id && !authedUserIsManager) { - return new Response("Unauthorized", { status: 401 }); + unauthorized(); } if (team.ownerId === user.id) { @@ -75,5 +75,14 @@ export async function POST(req: NextRequest) { data: { users: { disconnect: { id: body.data.userId } } }, }); + after(async () => { + await auditLog.createAuditLog({ + userEmail: authedUser.email, + action: "TEAM_MEMBER_REMOVED", + target: user.email, + details: `Removed ${user.name ?? user.email} from team ${team.name}`, + }); + }); + return new Response("OK", { status: 200 }); } diff --git a/src/app/api/team/transfer-ownership/route.ts b/src/app/api/team/transfer-ownership/route.ts index d60b3dccd..9650f7e71 100644 --- a/src/app/api/team/transfer-ownership/route.ts +++ b/src/app/api/team/transfer-ownership/route.ts @@ -1,9 +1,11 @@ import { getUser } from "@/data/user-dto"; +import { auditLog } from "@/lib/audit-logs"; import { auth } from "@/lib/auth"; -import Logger from "@/lib/logger"; +import { Logger } from "@/lib/logger"; import prisma from "@/lib/prisma"; import { $Enums } from "@prisma/client"; -import { NextRequest } from "next/server"; +import { forbidden, unauthorized } from "next/navigation"; +import { after, type NextRequest } from "next/server"; export async function POST(req: NextRequest) { const params = req.nextUrl.searchParams; @@ -20,7 +22,7 @@ export async function POST(req: NextRequest) { if (!session) { if (token !== process.env.DEV_TOKEN) { Logger.warn("Unauthorized request to remove team: ", id); - return new Response("Unauthorized", { status: 401 }); + unauthorized(); } Logger.log("Authorized removal of team with dev token"); } @@ -36,7 +38,7 @@ export async function POST(req: NextRequest) { user.role === $Enums.UserRole.MANAGER || // Managers can transfer anything user.id === team.ownerId; // Creators can transfer their own teams - if (!hasPerms) return new Response("Unauthorized", { status: 401 }); + if (!hasPerms) forbidden(); const newOwner = await getUser(owner); if (!newOwner) return new Response("New owner not found", { status: 404 }); @@ -52,5 +54,14 @@ export async function POST(req: NextRequest) { data: { users: { connect: { id: newOwner.id } } }, }); + after(async () => { + await auditLog.createAuditLog({ + userEmail: user.email, + action: "TEAM_OWNERSHIP_TRANSFERRED", + target: newOwner.email, + details: `Transferred ownership of team ${team.name} to ${newOwner.name}`, + }); + }); + return new Response("OK", { status: 200 }); } diff --git a/src/app/api/team/update-avatar/route.ts b/src/app/api/team/update-avatar/route.ts index 2c909ed23..ce1271118 100644 --- a/src/app/api/team/update-avatar/route.ts +++ b/src/app/api/team/update-avatar/route.ts @@ -1,8 +1,10 @@ import { getUser } from "@/data/user-dto"; +import { auditLog } from "@/lib/audit-logs"; import { auth } from "@/lib/auth"; -import Logger from "@/lib/logger"; +import { Logger } from "@/lib/logger"; import prisma from "@/lib/prisma"; -import { NextRequest } from "next/server"; +import { unauthorized } from "next/navigation"; +import { after, type NextRequest } from "next/server"; import { z } from "zod"; const TeamAvatarUpdateSchema = z.object({ @@ -12,9 +14,9 @@ const TeamAvatarUpdateSchema = z.object({ export async function POST(req: NextRequest) { const session = await auth(); - if (!session || !session.user) { + if (!session?.user) { Logger.log("No session found", session); - return new Response("Unauthorized", { status: 401 }); + unauthorized(); } const body = TeamAvatarUpdateSchema.safeParse(await req.json()); @@ -35,7 +37,7 @@ export async function POST(req: NextRequest) { const authedUser = await getUser(session.user.email); if (!authedUser) { Logger.log("User not found", session.user.email); - return new Response("Unauthorized", { status: 401 }); + unauthorized(); } if ( @@ -43,7 +45,7 @@ export async function POST(req: NextRequest) { teamManagers.some((m) => m.userId === authedUser.id) === false ) { Logger.log("Not a team owner or manager", team.name, authedUser.email); - return new Response("Unauthorized", { status: 401 }); + unauthorized(); } await prisma.team.update({ @@ -53,5 +55,14 @@ export async function POST(req: NextRequest) { Logger.log("new avatar uploaded for team: ", team.name, body.data.image); + after(async () => { + await auditLog.createAuditLog({ + userEmail: authedUser.email, + action: "TEAM_AVATAR_UPDATED", + target: team.name, + details: `Updated avatar for team ${team.name}`, + }); + }); + return new Response("OK", { status: 200 }); } diff --git a/src/app/api/team/update-name/route.ts b/src/app/api/team/update-name/route.ts index 8afde6c84..175aaad98 100644 --- a/src/app/api/team/update-name/route.ts +++ b/src/app/api/team/update-name/route.ts @@ -1,6 +1,8 @@ +import { auditLog } from "@/lib/audit-logs"; import { auth } from "@/lib/auth"; import prisma from "@/lib/prisma"; -import { NextRequest } from "next/server"; +import { unauthorized } from "next/navigation"; +import { after, type NextRequest } from "next/server"; import { z } from "zod"; const TeamNameUpdateSchema = z.object({ @@ -21,7 +23,7 @@ const TeamNameUpdateSchema = z.object({ export async function POST(req: NextRequest) { const session = await auth(); if (!session || !session.user || !session.user.email) { - return new Response("Unauthorized", { status: 401 }); + unauthorized(); } const body = TeamNameUpdateSchema.safeParse(await req.json()); @@ -35,5 +37,14 @@ export async function POST(req: NextRequest) { }, }); + after(async () => { + await auditLog.createAuditLog({ + userEmail: session.user.email, + action: "TEAM_UPDATED", + target: body.data.name, + details: `Updated name for team ${body.data.name}`, + }); + }); + return new Response("OK", { status: 200 }); } diff --git a/src/app/api/user/avatar-upload/route.ts b/src/app/api/user/avatar-upload/route.ts index 9495625be..21bc6d699 100644 --- a/src/app/api/user/avatar-upload/route.ts +++ b/src/app/api/user/avatar-upload/route.ts @@ -1,18 +1,19 @@ import { getUser } from "@/data/user-dto"; import { auth } from "@/lib/auth"; -import Logger from "@/lib/logger"; +import { Logger } from "@/lib/logger"; import prisma from "@/lib/prisma"; import { Ratelimit } from "@upstash/ratelimit"; import { track } from "@vercel/analytics/server"; import { handleUpload, type HandleUploadBody } from "@vercel/blob/client"; import { kv } from "@vercel/kv"; -import { NextRequest, NextResponse } from "next/server"; +import { unauthorized } from "next/navigation"; +import { type NextRequest, NextResponse } from "next/server"; export async function POST(request: NextRequest): Promise { const session = await auth(); - if (!session || !session.user) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + if (!session?.user) { + unauthorized(); } const authedUser = await getUser(session.user.email); @@ -50,18 +51,18 @@ export async function POST(request: NextRequest): Promise { const jsonResponse = await handleUpload({ body, request, - onBeforeGenerateToken: async ( - pathname: string + onBeforeGenerateToken: async () => + // pathname: string /* clientPayload?: string, */ - ) => { - // Generate a client token for the browser to upload the file - // ⚠️ Authenticate and authorize users before generating the token. - // Otherwise, you're allowing anonymous uploads. - const user = await prisma.user.findUnique({ where: { id: userId } }); - if (!user) throw new Error("User not found"); - - return { tokenPayload: JSON.stringify({ userId: user.id }) }; - }, + { + // Generate a client token for the browser to upload the file + // ⚠️ Authenticate and authorize users before generating the token. + // Otherwise, you're allowing anonymous uploads. + const user = await prisma.user.findUnique({ where: { id: userId } }); + if (!user) throw new Error("User not found"); + + return { tokenPayload: JSON.stringify({ userId: user.id }) }; + }, onUploadCompleted: async ({ blob, tokenPayload }) => { // Get notified of client upload completion // ⚠️ This will not work on `localhost` websites, @@ -81,7 +82,7 @@ export async function POST(request: NextRequest): Promise { where: { id: user?.id }, data: { image: blob.url }, }); - } catch (error) { + } catch { throw new Error("Could not update user"); } }, diff --git a/src/app/api/user/delete-account/route.ts b/src/app/api/user/delete-account/route.ts index 33f0f4e62..1e6670d81 100644 --- a/src/app/api/user/delete-account/route.ts +++ b/src/app/api/user/delete-account/route.ts @@ -1,20 +1,22 @@ import { getUser } from "@/data/user-dto"; +import { auditLog } from "@/lib/audit-logs"; import { auth } from "@/lib/auth"; -import Logger from "@/lib/logger"; +import { Logger } from "@/lib/logger"; import prisma from "@/lib/prisma"; import { deleteUserWebhookConstructor, sendDiscordWebhook, } from "@/lib/webhooks"; import { track } from "@vercel/analytics/server"; -import { NextRequest } from "next/server"; +import { unauthorized } from "next/navigation"; +import { after } from "next/server"; -export async function DELETE(req: NextRequest) { +export async function DELETE() { const session = await auth(); - if (!session) return new Response("Unauthorized", { status: 401 }); + if (!session) unauthorized(); const user = await getUser(session.user.email); - if (!user) return new Response("Unauthorized", { status: 401 }); + if (!user) unauthorized(); await track("User Deleted Account", { email: user.email }); @@ -25,5 +27,14 @@ export async function DELETE(req: NextRequest) { await prisma.user.delete({ where: { id: user.id } }); + after(async () => { + await auditLog.createAuditLog({ + userEmail: user.email, + action: "USER_ACCOUNT_DELETED", + target: user.email, + details: `User deleted their account`, + }); + }); + return new Response("OK", { status: 200 }); } diff --git a/src/app/api/user/update-avatar/route.ts b/src/app/api/user/update-avatar/route.ts index a8ae56746..4018819c7 100644 --- a/src/app/api/user/update-avatar/route.ts +++ b/src/app/api/user/update-avatar/route.ts @@ -1,19 +1,19 @@ +import { auditLog } from "@/lib/audit-logs"; import { auth } from "@/lib/auth"; -import Logger from "@/lib/logger"; +import { Logger } from "@/lib/logger"; import prisma from "@/lib/prisma"; -import { NextRequest } from "next/server"; +import { unauthorized } from "next/navigation"; +import { after, type NextRequest } from "next/server"; import { z } from "zod"; const AvatarUpdateSchema = z.object({ userId: z.string().min(1), - image: z.string().url(), + image: z.url(), }); export async function POST(req: NextRequest) { const session = await auth(); - if (!session || !session.user) { - return new Response("Unauthorized", { status: 401 }); - } + if (!session?.user) unauthorized(); const body = AvatarUpdateSchema.safeParse(await req.json()); if (!body.success) { @@ -26,7 +26,7 @@ export async function POST(req: NextRequest) { if (!user) return new Response("Not found", { status: 404 }); if (user.email !== session.user.email) { - return new Response("Unauthorized", { status: 401 }); + unauthorized(); } await prisma.user.update({ @@ -36,5 +36,14 @@ export async function POST(req: NextRequest) { Logger.log("new avatar uploaded for user: ", user.email, body.data.image); + after(async () => { + await auditLog.createAuditLog({ + userEmail: user.email, + action: "USER_AVATAR_UPDATED", + target: user.email, + details: `Updated avatar for user ${user.email}`, + }); + }); + return new Response("OK", { status: 200 }); } diff --git a/src/app/api/user/update-name/route.ts b/src/app/api/user/update-name/route.ts index 174753c8b..48316c0f5 100644 --- a/src/app/api/user/update-name/route.ts +++ b/src/app/api/user/update-name/route.ts @@ -1,6 +1,8 @@ +import { auditLog } from "@/lib/audit-logs"; import { auth } from "@/lib/auth"; import prisma from "@/lib/prisma"; -import { NextRequest } from "next/server"; +import { unauthorized } from "next/navigation"; +import { after, type NextRequest } from "next/server"; import { z } from "zod"; const UpdateNameSchema = z.object({ @@ -18,9 +20,7 @@ const UpdateNameSchema = z.object({ export async function POST(req: NextRequest) { const session = await auth(); - if (!session || !session.user || !session.user.email) { - return new Response("Unauthorized", { status: 401 }); - } + if (!session || !session.user || !session.user.email) unauthorized(); const body = UpdateNameSchema.safeParse(await req.json()); if (!body.success) { @@ -32,5 +32,14 @@ export async function POST(req: NextRequest) { data: { name: body.data.name }, }); + after(async () => { + await auditLog.createAuditLog({ + userEmail: session.user.email, + action: "USER_NAME_UPDATED", + target: session.user.email, + details: `Updated name for user ${session.user.email} to ${body.data.name}`, + }); + }); + return new Response("OK", { status: 200 }); } diff --git a/src/app/contact/layout.tsx b/src/app/contact/layout.tsx index 49fa3aca0..e316a281c 100644 --- a/src/app/contact/layout.tsx +++ b/src/app/contact/layout.tsx @@ -1,11 +1,7 @@ -import Footer from "@/components/footer"; -import Header from "@/components/header"; +import { Footer } from "@/components/footer"; +import { Header } from "@/components/header"; -export default function ContactLayout({ - children, -}: { - children: React.ReactNode; -}) { +export default function ContactLayout({ children }: LayoutProps<"/contact">) { return ( <> diff --git a/src/app/contact/page.tsx b/src/app/contact/page.tsx index ac325cf7d..badf9e44c 100644 --- a/src/app/contact/page.tsx +++ b/src/app/contact/page.tsx @@ -12,9 +12,8 @@ import { } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; -import { toast } from "@/components/ui/use-toast"; import { ClientOnly } from "@/lib/client-only"; -import Logger from "@/lib/logger"; +import { Logger } from "@/lib/logger"; import { EnvelopeIcon, UserGroupIcon } from "@heroicons/react/24/outline"; import { zodResolver } from "@hookform/resolvers/zod"; import { EnvelopeOpenIcon, ExternalLinkIcon } from "@radix-ui/react-icons"; @@ -22,6 +21,7 @@ import { useTranslations } from "next-intl"; import Link from "next/link"; import { useState } from "react"; import { useForm } from "react-hook-form"; +import { toast } from "sonner"; import { z } from "zod"; export default function ContactPage() { @@ -32,19 +32,15 @@ export default function ContactPage() { const formSchema = z.object({ name: z .string({ - required_error: t("contactForm.nameRequiredError"), + error: t("contactForm.nameRequiredError"), }) .min(1), - email: z - .string({ - required_error: t("contactForm.emailRequiredError"), - }) - .email({ - message: t("contactForm.emailMessage"), - }), + email: z.email({ + message: t("contactForm.emailMessage"), + }), message: z .string({ - required_error: t("contactForm.messageRequiredError"), + error: t("contactForm.messageRequiredError"), }) .min(1, { message: t("contactForm.messageMessage"), @@ -65,27 +61,23 @@ export default function ContactPage() { }); if (res.ok) { - toast({ - title: t("contactForm.handleSubmit.title"), + toast.success(t("contactForm.handleSubmit.title"), { description: t("contactForm.handleSubmit.description"), duration: 5000, }); form.reset(); } else { Logger.error("Error sending email", res.statusText); - toast({ - title: t("contactForm.handleSubmit.errorTitle1"), + toast.error(t("contactForm.handleSubmit.errorTitle1"), { description: t("contactForm.handleSubmit.errorDescription1", { res: `${await res.text()} (${res.status})`, }), duration: 5000, - variant: "destructive", }); } } catch (error) { Logger.error("Error sending email", error); - toast({ - title: t("contactForm.handleSubmit.errorTitle2"), + toast.error(t("contactForm.handleSubmit.errorTitle2"), { description: t("contactForm.handleSubmit.errorDescription2"), duration: 5000, }); @@ -94,13 +86,13 @@ export default function ContactPage() { setLoading(false); } return ( - +-+-+
-
{values.map((value) => (
+
{t("teamSection.title")}
@@ -294,7 +295,7 @@ export default async function AboutPage() { width={324} height={300} /> -
+
{person.name}
@@ -324,7 +325,7 @@ export default async function AboutPage() {