feat: initialize project with Next.js, Prisma, and Tailwind CSS

- Add package.json with dependencies and scripts for Next.js and Prisma
- Implement API routes for session management, user authentication, and company configuration
- Create database schema for Company, User, and Session models in Prisma
- Set up authentication with NextAuth and JWT
- Add password reset functionality and user registration endpoint
- Configure Tailwind CSS and PostCSS for styling
- Implement metrics and dashboard settings API endpoints
This commit is contained in:
2025-05-21 20:44:56 +02:00
commit cdaa3ea19d
36 changed files with 7662 additions and 0 deletions

View File

@ -0,0 +1,60 @@
// API route to refresh (fetch+parse+update) session data for a company
import { NextApiRequest, NextApiResponse } from "next";
import { fetchAndParseCsv } from "../../../lib/csvFetcher";
import { prisma } from "../../../lib/prisma";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
// Check if this is a POST request
if (req.method !== "POST") {
return res.status(405).json({ error: "Method not allowed" });
}
// Get companyId from body or query
let { companyId } = req.body;
if (!companyId) {
// Try to get user from prisma based on session cookie
try {
const session = await prisma.session.findFirst({
orderBy: { createdAt: 'desc' },
where: { /* Add session check criteria here */ }
});
if (session) {
companyId = session.companyId;
}
} catch (error) {
console.error("Error fetching session:", error);
}
}
if (!companyId) {
return res.status(400).json({ error: "Company ID is required" });
}
const company = await prisma.company.findUnique({ where: { id: companyId } });
if (!company) return res.status(404).json({ error: "Company not found" });
try {
// @ts-expect-error - Handle type conversion on session import
const sessions = await fetchAndParseCsv(company.csvUrl, company.csvUsername as string | undefined, company.csvPassword as string | undefined);
// Replace all session rows for this company (for demo simplicity)
await prisma.session.deleteMany({ where: { companyId: company.id } });
for (const session of sessions) {
// @ts-expect-error - Proper data mapping would be needed for production
await prisma.session.create({
// @ts-expect-error - We ensure id is present but TypeScript doesn't know
data: {
...session,
id: session.id || session.sessionId || `sess_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`,
companyId: company.id,
},
});
}
res.json({ ok: true, imported: sessions.length });
} catch (e) {
const error = e instanceof Error ? e.message : 'An unknown error occurred';
res.status(500).json({ error });
}
}

View File

@ -0,0 +1,89 @@
import NextAuth, { NextAuthOptions } from "next-auth";
import CredentialsProvider from "next-auth/providers/credentials";
import { prisma } from "../../../lib/prisma";
import bcrypt from "bcryptjs";
// Define the shape of the JWT token
declare module "next-auth/jwt" {
interface JWT {
companyId: string;
role: string;
}
}
// Define the shape of the session object
declare module "next-auth" {
interface Session {
user: {
id?: string;
name?: string;
email?: string;
image?: string;
companyId: string;
role: string;
};
}
interface User {
id: string;
email: string;
companyId: string;
role: string;
}
}
export const authOptions: NextAuthOptions = {
providers: [
CredentialsProvider({
name: "Credentials",
credentials: {
email: { label: "Email", type: "text" },
password: { label: "Password", type: "password" },
},
async authorize(credentials) {
if (!credentials?.email || !credentials?.password) {
return null;
}
const user = await prisma.user.findUnique({
where: { email: credentials.email }
});
if (!user) return null;
const valid = await bcrypt.compare(credentials.password, user.password);
if (!valid) return null;
return {
id: user.id,
email: user.email,
companyId: user.companyId,
role: user.role,
};
},
}),
],
session: { strategy: "jwt" },
callbacks: {
async jwt({ token, user }) {
if (user) {
token.companyId = user.companyId;
token.role = user.role;
}
return token;
},
async session({ session, token }) {
if (token && session.user) {
session.user.companyId = token.companyId;
session.user.role = token.role;
}
return session;
},
},
pages: {
signIn: "/login",
},
secret: process.env.NEXTAUTH_SECRET || "fallback-secret-key-change-in-production",
};
export default NextAuth(authOptions);

View File

@ -0,0 +1,27 @@
// API endpoint: update company CSV URL config
import { NextApiRequest, NextApiResponse } from "next";
import { getServerSession } from "next-auth";
import { prisma } from "../../../lib/prisma";
import { authOptions } from "../auth/[...nextauth]";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const session = await getServerSession(req, res, authOptions);
if (!session?.user) return res.status(401).json({ error: "Not logged in" });
const user = await prisma.user.findUnique({
where: { email: session.user.email as string }
});
if (!user) return res.status(401).json({ error: "No user" });
if (req.method === "POST") {
const { csvUrl } = req.body;
await prisma.company.update({
where: { id: user.companyId },
data: { csvUrl }
});
res.json({ ok: true });
} else {
res.status(405).end();
}
}

View File

@ -0,0 +1,44 @@
// API endpoint: return metrics for current company
import { NextApiRequest, NextApiResponse } from "next";
import { getServerSession } from "next-auth";
import { prisma } from "../../../lib/prisma";
import { sessionMetrics } from "../../../lib/metrics";
import { authOptions } from "../auth/[...nextauth]";
interface SessionUser {
email: string;
name?: string;
}
interface SessionData {
user: SessionUser;
}
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
const session = await getServerSession(req, res, authOptions) as SessionData | null;
if (!session?.user) return res.status(401).json({ error: "Not logged in" });
const user = await prisma.user.findUnique({
where: { email: session.user.email },
include: { company: true }
});
if (!user) return res.status(401).json({ error: "No user" });
const sessions = await prisma.session.findMany({
where: { companyId: user.companyId }
});
// Pass company config to metrics
// @ts-expect-error - Type conversion is needed between prisma session and ChatSession
const metrics = sessionMetrics(sessions, user.company);
res.json({
metrics,
csvUrl: user.company.csvUrl,
company: user.company
});
}

View File

@ -0,0 +1,32 @@
import { NextApiRequest, NextApiResponse } from "next";
import { getServerSession } from "next-auth";
import { prisma } from "../../../lib/prisma";
import { authOptions } from "../auth/[...nextauth]";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const session = await getServerSession(req, res, authOptions);
if (!session?.user || session.user.role !== "admin")
return res.status(403).json({ error: "Forbidden" });
const user = await prisma.user.findUnique({
where: { email: session.user.email as string }
});
if (!user) return res.status(401).json({ error: "No user" });
if (req.method === "POST") {
const { csvUrl, csvUsername, csvPassword, sentimentThreshold } = req.body;
await prisma.company.update({
where: { id: user.companyId },
data: {
csvUrl,
csvUsername,
...(csvPassword ? { csvPassword } : {}),
sentimentAlert: sentimentThreshold ? parseFloat(sentimentThreshold) : null,
}
});
res.json({ ok: true });
} else {
res.status(405).end();
}
}

View File

@ -0,0 +1,56 @@
import { NextApiRequest, NextApiResponse } from "next";
import { getServerSession } from "next-auth";
import { prisma } from "../../../lib/prisma";
import bcrypt from "bcryptjs";
import { authOptions } from "../auth/[...nextauth]";
// User type from prisma is used instead of the one in lib/types
interface UserBasicInfo {
id: string;
email: string;
role: string;
}
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const session = await getServerSession(req, res, authOptions);
if (!session?.user || session.user.role !== "admin")
return res.status(403).json({ error: "Forbidden" });
const user = await prisma.user.findUnique({
where: { email: session.user.email as string }
});
if (!user) return res.status(401).json({ error: "No user" });
if (req.method === "GET") {
const users = await prisma.user.findMany({
where: { companyId: user.companyId }
});
const mappedUsers: UserBasicInfo[] = users.map(u => ({
id: u.id,
email: u.email,
role: u.role
}));
res.json({ users: mappedUsers });
}
else if (req.method === "POST") {
const { email, role } = req.body;
if (!email || !role) return res.status(400).json({ error: "Missing fields" });
const exists = await prisma.user.findUnique({ where: { email } });
if (exists) return res.status(409).json({ error: "Email exists" });
const tempPassword = Math.random().toString(36).slice(-8); // random initial password
await prisma.user.create({
data: {
email,
password: await bcrypt.hash(tempPassword, 10),
companyId: user.companyId,
role,
}
});
// TODO: Email user their temp password (stub, for demo)
res.json({ ok: true, tempPassword });
}
else res.status(405).end();
}

View File

@ -0,0 +1,35 @@
import { prisma } from "../../lib/prisma";
import { sendEmail } from "../../lib/sendEmail";
import crypto from "crypto";
import type { IncomingMessage, ServerResponse } from 'http';
type NextApiRequest = IncomingMessage & {
body: {
email: string;
[key: string]: unknown;
};
};
type NextApiResponse = ServerResponse & {
status: (code: number) => NextApiResponse;
json: (data: Record<string, unknown>) => void;
end: () => void;
};
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== "POST") return res.status(405).end();
const { email } = req.body;
const user = await prisma.user.findUnique({ where: { email } });
if (!user) return res.status(200).end(); // always 200 for privacy
const token = crypto.randomBytes(32).toString("hex");
const expiry = new Date(Date.now() + 1000 * 60 * 30); // 30 min expiry
await prisma.user.update({
where: { email },
data: { resetToken: token, resetTokenExpiry: expiry },
});
const resetUrl = `${process.env.NEXTAUTH_URL || "http://localhost:3000"}/reset-password?token=${token}`;
await sendEmail(email, "Password Reset", `Reset your password: ${resetUrl}`);
res.status(200).end();
}

53
pages/api/register.ts Normal file
View File

@ -0,0 +1,53 @@
import { NextApiRequest, NextApiResponse } from "next";
import { prisma } from "../../lib/prisma";
import bcrypt from "bcryptjs";
import { ApiResponse } from "../../lib/types";
interface RegisterRequestBody {
email: string;
password: string;
company: string;
csvUrl?: string;
}
export default async function handler(req: NextApiRequest, res: NextApiResponse<ApiResponse<{ success: boolean; } | { error: string; }>>) {
if (req.method !== "POST") return res.status(405).end();
const { email, password, company, csvUrl } = req.body as RegisterRequestBody;
if (!email || !password || !company) {
return res.status(400).json({
success: false,
error: "Missing required fields"
});
}
// Check if email exists
const exists = await prisma.user.findUnique({
where: { email }
});
if (exists) {
return res.status(409).json({
success: false,
error: "Email already exists"
});
}
const newCompany = await prisma.company.create({
data: { name: company, csvUrl: csvUrl || "" },
});
const hashed = await bcrypt.hash(password, 10);
await prisma.user.create({
data: {
email,
password: hashed,
companyId: newCompany.id,
role: "admin",
},
});
res.status(201).json({
success: true,
data: { success: true }
});
}

View File

@ -0,0 +1,40 @@
import { prisma } from "../../lib/prisma";
import bcrypt from "bcryptjs";
import type { IncomingMessage, ServerResponse } from 'http';
type NextApiRequest = IncomingMessage & {
body: {
token: string;
password: string;
[key: string]: unknown;
};
};
type NextApiResponse = ServerResponse & {
status: (code: number) => NextApiResponse;
json: (data: Record<string, unknown>) => void;
end: () => void;
};
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== "POST") return res.status(405).end();
const { token, password } = req.body;
const user = await prisma.user.findFirst({
where: {
resetToken: token,
resetTokenExpiry: { gte: new Date() }
}
});
if (!user) return res.status(400).json({ error: "Invalid or expired token" });
const hash = await bcrypt.hash(password, 10);
await prisma.user.update({
where: { id: user.id },
data: {
password: hash,
resetToken: null,
resetTokenExpiry: null,
}
});
res.status(200).end();
}