Skip to content
Merged
24 changes: 21 additions & 3 deletions dashboard/app/api/v1/events/route.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,36 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { createEvent } from "@/server/events";
import { createEventForUser, SessionNotFoundError } from "@/server/events";
import { CreateEventInput } from "@/server/schemas/events";
import { withCors, corsPreflight } from "@/server/cors";
import { requireUser, UnauthorizedError } from "@/server/jwt";

export const POST = withCors(async (req) => {
let user;
try {
user = requireUser(req);
} catch (err) {
if (err instanceof UnauthorizedError) {
return NextResponse.json({ error: err.message }, { status: 401 });
}
throw err;
}

const body = await req.json();
const parsed = CreateEventInput.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: z.treeifyError(parsed.error) }, { status: 400 });
}

const event = await createEvent(parsed.data);
return NextResponse.json(event, { status: 201 });
try {
const event = await createEventForUser(user.id, parsed.data);
return NextResponse.json(event, { status: 201 });
} catch (err) {
if (err instanceof SessionNotFoundError) {
return NextResponse.json({ error: "Session not found" }, { status: 404 });
}
throw err;
}
});

export function OPTIONS(req: NextRequest) {
Expand Down
44 changes: 44 additions & 0 deletions dashboard/app/api/v1/sessions/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { endSessionForUser, SessionNotFoundError } from "@/server/sessions";
import { SessionIdParam } from "@/server/schemas/sessions";
import { withCors, corsPreflight } from "@/server/cors";
import { requireUser, UnauthorizedError } from "@/server/jwt";

interface RouteContext {
params: Promise<{ id: string }>;
}

export function PATCH(req: NextRequest, ctx: RouteContext): Promise<NextResponse> {
return withCors(async (r) => {
let user;
try {
user = requireUser(r);
} catch (err) {
if (err instanceof UnauthorizedError) {
return NextResponse.json({ error: err.message }, { status: 401 });
}
throw err;
}

const params = await ctx.params;
const parsed = SessionIdParam.safeParse(params);
if (!parsed.success) {
return NextResponse.json({ error: z.treeifyError(parsed.error) }, { status: 400 });
}

try {
const session = await endSessionForUser(user.id, parsed.data.id);
return NextResponse.json(session, { status: 200 });
} catch (err) {
if (err instanceof SessionNotFoundError) {
return NextResponse.json({ error: "Session not found" }, { status: 404 });
}
throw err;
}
})(req);
}

export function OPTIONS(req: NextRequest) {
return corsPreflight(req);
}
23 changes: 23 additions & 0 deletions dashboard/app/api/v1/sessions/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { NextRequest, NextResponse } from "next/server";
import { createSessionForUser } from "@/server/sessions";
import { withCors, corsPreflight } from "@/server/cors";
import { requireUser, UnauthorizedError } from "@/server/jwt";

export const POST = withCors(async (req) => {
let user;
try {
user = requireUser(req);
} catch (err) {
if (err instanceof UnauthorizedError) {
return NextResponse.json({ error: err.message }, { status: 401 });
}
throw err;
}

const session = await createSessionForUser(user.id);
return NextResponse.json(session, { status: 201 });
});

export function OPTIONS(req: NextRequest) {
return corsPreflight(req);
}
5 changes: 4 additions & 1 deletion dashboard/next.config.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import path from "node:path";
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
/* config options here */
turbopack: {
root: path.resolve(import.meta.dirname),
},
};

export default nextConfig;
18 changes: 17 additions & 1 deletion dashboard/server/events.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,22 @@
import type { CreateEventInput } from "./schemas/events";
import { prisma } from "./db";

export function createEvent(input: CreateEventInput) {
export class SessionNotFoundError extends Error {
constructor() {
super("Session not found");
this.name = "SessionNotFoundError";
}
}

export async function createEventForUser(
userId: string,
input: CreateEventInput,
) {
const session = await prisma.session.findFirst({
where: { id: input.sessionId, userId },
select: { id: true },
});
if (!session) throw new SessionNotFoundError();

return prisma.event.create({ data: input });
}
42 changes: 42 additions & 0 deletions dashboard/server/jwt.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import type { NextRequest } from "next/server";
import jwt from "jsonwebtoken";

export class UnauthorizedError extends Error {
constructor(message = "Unauthorized") {
super(message);
this.name = "UnauthorizedError";
}
}

interface JwtPayload {
sub: string;
email: string;
}

function isPayload(v: unknown): v is JwtPayload {
if (typeof v !== "object" || v === null) return false;
const r = v as Record<string, unknown>;
return typeof r["sub"] === "string" && typeof r["email"] === "string";
}

export function requireUser(req: NextRequest): { id: string; email: string } {
const header = req.headers.get("authorization");
if (!header?.startsWith("Bearer ")) {
throw new UnauthorizedError("Missing or malformed Authorization header");
}
const token = header.slice("Bearer ".length).trim();
if (!token) throw new UnauthorizedError("Empty bearer token");

const secret = process.env.JWT_SECRET;
if (!secret) throw new Error("JWT_SECRET is not set");

let decoded: unknown;
try {
decoded = jwt.verify(token, secret);
} catch {
throw new UnauthorizedError("Invalid or expired token");
}
if (!isPayload(decoded)) throw new UnauthorizedError("Invalid token payload");

return { id: decoded.sub, email: decoded.email };
}
7 changes: 7 additions & 0 deletions dashboard/server/schemas/sessions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { z } from "zod";

export const SessionIdParam = z.object({
id: z.string().regex(/^c[a-z0-9]{24}$/, "must be a CUID"),
});

export type SessionIdParam = z.infer<typeof SessionIdParam>;
28 changes: 28 additions & 0 deletions dashboard/server/sessions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { prisma } from "./db";

export class SessionNotFoundError extends Error {
constructor() {
super("Session not found");
this.name = "SessionNotFoundError";
}
}

export function createSessionForUser(userId: string) {
return prisma.session.create({
data: { userId },
select: { id: true, startedAt: true },
});
}

export async function endSessionForUser(userId: string, sessionId: string) {
const result = await prisma.session.updateMany({
where: { id: sessionId, userId, endedAt: null },
data: { endedAt: new Date() },
});
if (result.count === 0) throw new SessionNotFoundError();

return prisma.session.findUniqueOrThrow({
where: { id: sessionId },
select: { id: true, endedAt: true },
});
}
Loading
Loading