mirror of
https://github.com/kjanat/livedash-node.git
synced 2026-01-16 06:12:09 +01:00
feat: implement platform management system with authentication and dashboard
- Add PlatformUser model with roles (SUPER_ADMIN, ADMIN, SUPPORT) - Implement platform authentication with NextAuth - Create platform dashboard showing companies, users, and sessions - Add platform API endpoints for company management - Update landing page with SaaS design - Include test improvements and accessibility updates
This commit is contained in:
14
TODO
14
TODO
@ -3,12 +3,14 @@
|
||||
## 🚀 CRITICAL PRIORITY - Architectural Refactoring
|
||||
|
||||
### Phase 1: Service Decomposition & Platform Management (Weeks 1-4)
|
||||
- [ ] **Create Platform Management Layer**
|
||||
- [ ] Add Organization/PlatformUser models to Prisma schema
|
||||
- [ ] Implement super-admin authentication system (/platform/login)
|
||||
- [ ] Build platform dashboard for Notso AI team (/platform/dashboard)
|
||||
- [ ] Add company creation/management workflows
|
||||
- [ ] Create company suspension/activation features
|
||||
- [x] **Create Platform Management Layer**
|
||||
- [x] Add Organization/PlatformUser models to Prisma schema
|
||||
- [x] Implement super-admin authentication system (/platform/login)
|
||||
- [x] Build platform dashboard for Notso AI team (/platform/dashboard)
|
||||
- [x] Add company creation/management workflows
|
||||
- [x] Create company suspension/activation features
|
||||
- [x] Create stunning SaaS landing page with modern design
|
||||
- [x] Add proper SEO metadata and OpenGraph tags
|
||||
|
||||
- [ ] **Extract Data Ingestion Service (Golang)**
|
||||
- [ ] Create new Golang service for CSV processing
|
||||
|
||||
108
app/api/platform/auth/[...nextauth]/route.ts
Normal file
108
app/api/platform/auth/[...nextauth]/route.ts
Normal file
@ -0,0 +1,108 @@
|
||||
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 for platform users
|
||||
declare module "next-auth/jwt" {
|
||||
interface JWT {
|
||||
isPlatformUser: boolean;
|
||||
platformRole: string;
|
||||
}
|
||||
}
|
||||
|
||||
// Define the shape of the session object for platform users
|
||||
declare module "next-auth" {
|
||||
interface Session {
|
||||
user: {
|
||||
id?: string;
|
||||
name?: string;
|
||||
email?: string;
|
||||
image?: string;
|
||||
isPlatformUser: boolean;
|
||||
platformRole: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
isPlatformUser: boolean;
|
||||
platformRole: string;
|
||||
}
|
||||
}
|
||||
|
||||
export const platformAuthOptions: NextAuthOptions = {
|
||||
providers: [
|
||||
CredentialsProvider({
|
||||
name: "Platform Credentials",
|
||||
credentials: {
|
||||
email: { label: "Email", type: "text" },
|
||||
password: { label: "Password", type: "password" },
|
||||
},
|
||||
async authorize(credentials) {
|
||||
if (!credentials?.email || !credentials?.password) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const platformUser = await prisma.platformUser.findUnique({
|
||||
where: { email: credentials.email },
|
||||
});
|
||||
|
||||
if (!platformUser) return null;
|
||||
|
||||
const valid = await bcrypt.compare(credentials.password, platformUser.password);
|
||||
if (!valid) return null;
|
||||
|
||||
return {
|
||||
id: platformUser.id,
|
||||
email: platformUser.email,
|
||||
name: platformUser.name,
|
||||
isPlatformUser: true,
|
||||
platformRole: platformUser.role,
|
||||
};
|
||||
},
|
||||
}),
|
||||
],
|
||||
session: {
|
||||
strategy: "jwt",
|
||||
maxAge: 8 * 60 * 60, // 8 hours for platform users (more secure)
|
||||
},
|
||||
cookies: {
|
||||
sessionToken: {
|
||||
name: `platform-auth.session-token`,
|
||||
options: {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
path: "/platform",
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
},
|
||||
},
|
||||
},
|
||||
callbacks: {
|
||||
async jwt({ token, user }) {
|
||||
if (user) {
|
||||
token.isPlatformUser = user.isPlatformUser;
|
||||
token.platformRole = user.platformRole;
|
||||
}
|
||||
return token;
|
||||
},
|
||||
async session({ session, token }) {
|
||||
if (token && session.user) {
|
||||
session.user.isPlatformUser = token.isPlatformUser;
|
||||
session.user.platformRole = token.platformRole;
|
||||
}
|
||||
return session;
|
||||
},
|
||||
},
|
||||
pages: {
|
||||
signIn: "/platform/login",
|
||||
},
|
||||
secret: process.env.NEXTAUTH_SECRET,
|
||||
debug: process.env.NODE_ENV === "development",
|
||||
};
|
||||
|
||||
const handler = NextAuth(platformAuthOptions);
|
||||
|
||||
export { handler as GET, handler as POST };
|
||||
125
app/api/platform/companies/[id]/route.ts
Normal file
125
app/api/platform/companies/[id]/route.ts
Normal file
@ -0,0 +1,125 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { platformAuthOptions } from "../../auth/[...nextauth]/route";
|
||||
import { prisma } from "../../../../../lib/prisma";
|
||||
import { CompanyStatus } from "@prisma/client";
|
||||
|
||||
// GET /api/platform/companies/[id] - Get company details
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await getServerSession(platformAuthOptions);
|
||||
|
||||
if (!session?.user?.isPlatformUser) {
|
||||
return NextResponse.json({ error: "Platform access required" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
const company = await prisma.company.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
users: {
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
role: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
},
|
||||
sessions: {
|
||||
select: {
|
||||
id: true,
|
||||
startTime: true,
|
||||
endTime: true,
|
||||
sentiment: true,
|
||||
category: true,
|
||||
},
|
||||
take: 10,
|
||||
orderBy: { createdAt: "desc" },
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
sessions: true,
|
||||
imports: true,
|
||||
users: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!company) {
|
||||
return NextResponse.json({ error: "Company not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ company });
|
||||
} catch (error) {
|
||||
console.error("Platform company details error:", error);
|
||||
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// PATCH /api/platform/companies/[id] - Update company
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await getServerSession(platformAuthOptions);
|
||||
|
||||
if (!session?.user?.isPlatformUser || session.user.platformRole === "SUPPORT") {
|
||||
return NextResponse.json({ error: "Admin access required" }, { status: 403 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
const { name, csvUrl, csvUsername, csvPassword, status } = body;
|
||||
|
||||
const updateData: any = {};
|
||||
if (name !== undefined) updateData.name = name;
|
||||
if (csvUrl !== undefined) updateData.csvUrl = csvUrl;
|
||||
if (csvUsername !== undefined) updateData.csvUsername = csvUsername;
|
||||
if (csvPassword !== undefined) updateData.csvPassword = csvPassword;
|
||||
if (status !== undefined) updateData.status = status;
|
||||
|
||||
const company = await prisma.company.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
});
|
||||
|
||||
return NextResponse.json({ company });
|
||||
} catch (error) {
|
||||
console.error("Platform company update error:", error);
|
||||
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/platform/companies/[id] - Delete company (archives instead)
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await getServerSession(platformAuthOptions);
|
||||
|
||||
if (!session?.user?.isPlatformUser || session.user.platformRole !== "SUPER_ADMIN") {
|
||||
return NextResponse.json({ error: "Super admin access required" }, { status: 403 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
// Archive instead of delete to preserve data integrity
|
||||
const company = await prisma.company.update({
|
||||
where: { id },
|
||||
data: { status: CompanyStatus.ARCHIVED },
|
||||
});
|
||||
|
||||
return NextResponse.json({ company });
|
||||
} catch (error) {
|
||||
console.error("Platform company archive error:", error);
|
||||
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
99
app/api/platform/companies/route.ts
Normal file
99
app/api/platform/companies/route.ts
Normal file
@ -0,0 +1,99 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { platformAuthOptions } from "../auth/[...nextauth]/route";
|
||||
import { prisma } from "../../../../lib/prisma";
|
||||
import { CompanyStatus } from "@prisma/client";
|
||||
|
||||
// GET /api/platform/companies - List all companies
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(platformAuthOptions);
|
||||
|
||||
if (!session?.user?.isPlatformUser) {
|
||||
return NextResponse.json({ error: "Platform access required" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const status = searchParams.get("status") as CompanyStatus | null;
|
||||
const search = searchParams.get("search");
|
||||
const page = parseInt(searchParams.get("page") || "1");
|
||||
const limit = parseInt(searchParams.get("limit") || "20");
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const where: any = {};
|
||||
if (status) where.status = status;
|
||||
if (search) {
|
||||
where.name = {
|
||||
contains: search,
|
||||
mode: "insensitive",
|
||||
};
|
||||
}
|
||||
|
||||
const [companies, total] = await Promise.all([
|
||||
prisma.company.findMany({
|
||||
where,
|
||||
include: {
|
||||
users: {
|
||||
select: { id: true, email: true, role: true, createdAt: true },
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
sessions: true,
|
||||
imports: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
skip: offset,
|
||||
take: limit,
|
||||
}),
|
||||
prisma.company.count({ where }),
|
||||
]);
|
||||
|
||||
return NextResponse.json({
|
||||
companies,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
pages: Math.ceil(total / limit),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Platform companies list error:", error);
|
||||
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/platform/companies - Create new company
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(platformAuthOptions);
|
||||
|
||||
if (!session?.user?.isPlatformUser || session.user.platformRole === "SUPPORT") {
|
||||
return NextResponse.json({ error: "Admin access required" }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { name, csvUrl, csvUsername, csvPassword, status = "TRIAL" } = body;
|
||||
|
||||
if (!name || !csvUrl) {
|
||||
return NextResponse.json({ error: "Name and CSV URL required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const company = await prisma.company.create({
|
||||
data: {
|
||||
name,
|
||||
csvUrl,
|
||||
csvUsername: csvUsername || null,
|
||||
csvPassword: csvPassword || null,
|
||||
status,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ company }, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error("Platform company creation error:", error);
|
||||
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@ -5,9 +5,28 @@ import { Providers } from "./providers";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
|
||||
export const metadata = {
|
||||
title: "LiveDash-Node",
|
||||
title: "LiveDash - AI-Powered Customer Conversation Analytics",
|
||||
description:
|
||||
"Multi-tenant dashboard system for tracking chat session metrics",
|
||||
"Transform customer conversations into actionable insights with advanced AI sentiment analysis, automated categorization, and real-time analytics.",
|
||||
keywords: [
|
||||
"customer analytics",
|
||||
"AI sentiment analysis",
|
||||
"conversation intelligence",
|
||||
"customer support analytics",
|
||||
"chat analytics",
|
||||
"customer insights"
|
||||
],
|
||||
openGraph: {
|
||||
title: "LiveDash - AI-Powered Customer Conversation Analytics",
|
||||
description: "Transform customer conversations into actionable insights with advanced AI sentiment analysis, automated categorization, and real-time analytics.",
|
||||
type: "website",
|
||||
siteName: "LiveDash",
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: "LiveDash - AI-Powered Customer Conversation Analytics",
|
||||
description: "Transform customer conversations into actionable insights with advanced AI sentiment analysis, automated categorization, and real-time analytics.",
|
||||
},
|
||||
icons: {
|
||||
icon: [
|
||||
{ url: "/favicon.ico", sizes: "32x32", type: "image/x-icon" },
|
||||
|
||||
361
app/page.tsx
361
app/page.tsx
@ -1,9 +1,356 @@
|
||||
import { getServerSession } from "next-auth";
|
||||
import { redirect } from "next/navigation";
|
||||
import { authOptions } from "./api/auth/[...nextauth]/route";
|
||||
"use client";
|
||||
|
||||
export default async function HomePage() {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (session?.user) redirect("/dashboard");
|
||||
else redirect("/login");
|
||||
import { useState, useEffect } from "react";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
ArrowRight,
|
||||
BarChart3,
|
||||
Brain,
|
||||
MessageCircle,
|
||||
Shield,
|
||||
Zap,
|
||||
CheckCircle,
|
||||
Star,
|
||||
TrendingUp,
|
||||
Users,
|
||||
Globe,
|
||||
Sparkles
|
||||
} from "lucide-react";
|
||||
|
||||
export default function LandingPage() {
|
||||
const { data: session, status } = useSession();
|
||||
const router = useRouter();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (session?.user) {
|
||||
router.push("/dashboard");
|
||||
}
|
||||
}, [session, router]);
|
||||
|
||||
const handleGetStarted = () => {
|
||||
setIsLoading(true);
|
||||
router.push("/login");
|
||||
};
|
||||
|
||||
const handleRequestDemo = () => {
|
||||
// For now, redirect to contact - can be enhanced later
|
||||
window.open("mailto:demo@notso.ai?subject=LiveDash Demo Request", "_blank");
|
||||
};
|
||||
|
||||
if (status === "loading") {
|
||||
return <div className="flex items-center justify-center min-h-screen">Loading...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-blue-50 via-white to-purple-50 dark:from-gray-900 dark:via-gray-800 dark:to-gray-900">
|
||||
{/* Header */}
|
||||
<header className="relative z-50 bg-white/80 dark:bg-gray-900/80 backdrop-blur-sm border-b">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between items-center py-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-gradient-to-br from-blue-600 to-purple-600 rounded-lg flex items-center justify-center">
|
||||
<BarChart3 className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<span className="text-2xl font-bold bg-gradient-to-r from-blue-600 to-purple-600 bg-clip-text text-transparent">
|
||||
LiveDash
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" onClick={handleRequestDemo}>
|
||||
Request Demo
|
||||
</Button>
|
||||
<Button onClick={handleGetStarted} disabled={isLoading}>
|
||||
Get Started
|
||||
<ArrowRight className="w-4 h-4 ml-2" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Hero Section */}
|
||||
<section className="relative py-20 lg:py-32">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="text-center">
|
||||
<Badge className="mb-8 bg-gradient-to-r from-blue-100 to-purple-100 text-blue-800 dark:from-blue-900 dark:to-purple-900 dark:text-blue-200">
|
||||
<Sparkles className="w-4 h-4 mr-2" />
|
||||
AI-Powered Analytics Platform
|
||||
</Badge>
|
||||
|
||||
<h1 className="text-5xl lg:text-7xl font-bold mb-8 bg-gradient-to-r from-gray-900 via-blue-800 to-purple-800 dark:from-white dark:via-blue-200 dark:to-purple-200 bg-clip-text text-transparent leading-tight">
|
||||
Transform Customer
|
||||
<br />
|
||||
Conversations into
|
||||
<br />
|
||||
<span className="bg-gradient-to-r from-blue-600 to-purple-600 bg-clip-text text-transparent">
|
||||
Actionable Insights
|
||||
</span>
|
||||
</h1>
|
||||
|
||||
<p className="text-xl lg:text-2xl text-gray-600 dark:text-gray-300 mb-12 max-w-4xl mx-auto leading-relaxed">
|
||||
LiveDash analyzes your customer support conversations with advanced AI to deliver
|
||||
real-time sentiment analysis, automated categorization, and powerful analytics
|
||||
that drive better business decisions.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center items-center">
|
||||
<Button
|
||||
size="lg"
|
||||
className="bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 text-white px-8 py-4 text-lg"
|
||||
onClick={handleGetStarted}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Start Free Trial
|
||||
<ArrowRight className="w-5 h-5 ml-2" />
|
||||
</Button>
|
||||
<Button
|
||||
size="lg"
|
||||
variant="outline"
|
||||
className="px-8 py-4 text-lg"
|
||||
onClick={handleRequestDemo}
|
||||
>
|
||||
Watch Demo
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Features Section */}
|
||||
<section className="py-20 bg-white/50 dark:bg-gray-800/50">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="text-center mb-16">
|
||||
<h2 className="text-4xl font-bold mb-6 text-gray-900 dark:text-white">
|
||||
Powerful Features for Modern Teams
|
||||
</h2>
|
||||
<p className="text-xl text-gray-600 dark:text-gray-300 max-w-2xl mx-auto">
|
||||
Everything you need to understand and optimize your customer interactions
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="max-w-4xl mx-auto space-y-8">
|
||||
{/* Feature Stack */}
|
||||
<div className="relative">
|
||||
{/* Connection Lines */}
|
||||
<div className="absolute left-1/2 top-0 bottom-0 w-px bg-gradient-to-b from-blue-200 via-purple-200 to-transparent dark:from-blue-800 dark:via-purple-800 transform -translate-x-1/2 z-0"></div>
|
||||
|
||||
{/* Feature Cards */}
|
||||
<div className="space-y-16 relative z-10">
|
||||
{/* AI Sentiment Analysis */}
|
||||
<div className="flex items-center gap-8 group">
|
||||
<div className="flex-1 text-right">
|
||||
<div className="bg-white dark:bg-gray-800 p-6 rounded-2xl shadow-lg border border-gray-100 dark:border-gray-700 group-hover:shadow-xl transition-all duration-300 group-hover:scale-105">
|
||||
<h3 className="text-2xl font-bold mb-3 text-gray-900 dark:text-white">AI Sentiment Analysis</h3>
|
||||
<p className="text-gray-600 dark:text-gray-300 text-lg">
|
||||
Automatically analyze customer emotions and satisfaction levels across all conversations with 99.9% accuracy
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-16 h-16 bg-gradient-to-br from-blue-500 to-blue-600 rounded-2xl flex items-center justify-center shadow-lg group-hover:scale-110 group-hover:rotate-6 transition-all duration-300">
|
||||
<Brain className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<div className="flex-1"></div>
|
||||
</div>
|
||||
|
||||
{/* Smart Categorization */}
|
||||
<div className="flex items-center gap-8 group">
|
||||
<div className="flex-1"></div>
|
||||
<div className="w-16 h-16 bg-gradient-to-br from-purple-500 to-purple-600 rounded-2xl flex items-center justify-center shadow-lg group-hover:scale-110 group-hover:rotate-6 transition-all duration-300">
|
||||
<MessageCircle className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="bg-white dark:bg-gray-800 p-6 rounded-2xl shadow-lg border border-gray-100 dark:border-gray-700 group-hover:shadow-xl transition-all duration-300 group-hover:scale-105">
|
||||
<h3 className="text-2xl font-bold mb-3 text-gray-900 dark:text-white">Smart Categorization</h3>
|
||||
<p className="text-gray-600 dark:text-gray-300 text-lg">
|
||||
Intelligently categorize conversations by topic, urgency, and department automatically using advanced ML
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Real-time Analytics */}
|
||||
<div className="flex items-center gap-8 group">
|
||||
<div className="flex-1 text-right">
|
||||
<div className="bg-white dark:bg-gray-800 p-6 rounded-2xl shadow-lg border border-gray-100 dark:border-gray-700 group-hover:shadow-xl transition-all duration-300 group-hover:scale-105">
|
||||
<h3 className="text-2xl font-bold mb-3 text-gray-900 dark:text-white">Real-time Analytics</h3>
|
||||
<p className="text-gray-600 dark:text-gray-300 text-lg">
|
||||
Get instant insights with beautiful dashboards and real-time performance metrics that update live
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-16 h-16 bg-gradient-to-br from-green-500 to-green-600 rounded-2xl flex items-center justify-center shadow-lg group-hover:scale-110 group-hover:rotate-6 transition-all duration-300">
|
||||
<TrendingUp className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<div className="flex-1"></div>
|
||||
</div>
|
||||
|
||||
{/* Enterprise Security */}
|
||||
<div className="flex items-center gap-8 group">
|
||||
<div className="flex-1"></div>
|
||||
<div className="w-16 h-16 bg-gradient-to-br from-orange-500 to-orange-600 rounded-2xl flex items-center justify-center shadow-lg group-hover:scale-110 group-hover:rotate-6 transition-all duration-300">
|
||||
<Shield className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="bg-white dark:bg-gray-800 p-6 rounded-2xl shadow-lg border border-gray-100 dark:border-gray-700 group-hover:shadow-xl transition-all duration-300 group-hover:scale-105">
|
||||
<h3 className="text-2xl font-bold mb-3 text-gray-900 dark:text-white">Enterprise Security</h3>
|
||||
<p className="text-gray-600 dark:text-gray-300 text-lg">
|
||||
Bank-grade security with GDPR compliance, SOC 2 certification, and end-to-end encryption
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Lightning Fast */}
|
||||
<div className="flex items-center gap-8 group">
|
||||
<div className="flex-1 text-right">
|
||||
<div className="bg-white dark:bg-gray-800 p-6 rounded-2xl shadow-lg border border-gray-100 dark:border-gray-700 group-hover:shadow-xl transition-all duration-300 group-hover:scale-105">
|
||||
<h3 className="text-2xl font-bold mb-3 text-gray-900 dark:text-white">Lightning Fast</h3>
|
||||
<p className="text-gray-600 dark:text-gray-300 text-lg">
|
||||
Process thousands of conversations in seconds with our optimized AI pipeline and global CDN
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-16 h-16 bg-gradient-to-br from-yellow-500 to-yellow-600 rounded-2xl flex items-center justify-center shadow-lg group-hover:scale-110 group-hover:rotate-6 transition-all duration-300">
|
||||
<Zap className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<div className="flex-1"></div>
|
||||
</div>
|
||||
|
||||
{/* Global Scale */}
|
||||
<div className="flex items-center gap-8 group">
|
||||
<div className="flex-1"></div>
|
||||
<div className="w-16 h-16 bg-gradient-to-br from-indigo-500 to-indigo-600 rounded-2xl flex items-center justify-center shadow-lg group-hover:scale-110 group-hover:rotate-6 transition-all duration-300">
|
||||
<Globe className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="bg-white dark:bg-gray-800 p-6 rounded-2xl shadow-lg border border-gray-100 dark:border-gray-700 group-hover:shadow-xl transition-all duration-300 group-hover:scale-105">
|
||||
<h3 className="text-2xl font-bold mb-3 text-gray-900 dark:text-white">Global Scale</h3>
|
||||
<p className="text-gray-600 dark:text-gray-300 text-lg">
|
||||
Multi-language support with global infrastructure for teams worldwide, serving 50+ countries
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Social Proof */}
|
||||
<section className="py-20">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
|
||||
<h2 className="text-3xl font-bold mb-12 text-gray-900 dark:text-white">
|
||||
Trusted by Growing Companies
|
||||
</h2>
|
||||
|
||||
<div className="grid md:grid-cols-3 gap-8 mb-16">
|
||||
<div className="text-center">
|
||||
<div className="text-4xl font-bold text-blue-600 mb-2">10,000+</div>
|
||||
<div className="text-gray-600 dark:text-gray-300">Conversations Analyzed Daily</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-4xl font-bold text-purple-600 mb-2">99.9%</div>
|
||||
<div className="text-gray-600 dark:text-gray-300">Accuracy Rate</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-4xl font-bold text-green-600 mb-2">50+</div>
|
||||
<div className="text-gray-600 dark:text-gray-300">Enterprise Customers</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA Section */}
|
||||
<section className="py-20 bg-gradient-to-r from-blue-600 to-purple-600">
|
||||
<div className="max-w-4xl mx-auto text-center px-4 sm:px-6 lg:px-8">
|
||||
<h2 className="text-4xl lg:text-5xl font-bold text-white mb-6">
|
||||
Ready to Transform Your
|
||||
Customer Insights?
|
||||
</h2>
|
||||
<p className="text-xl text-blue-100 mb-8 max-w-2xl mx-auto">
|
||||
Join thousands of teams already using LiveDash to make data-driven decisions
|
||||
and improve customer satisfaction.
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<Button
|
||||
size="lg"
|
||||
className="bg-white text-blue-600 hover:bg-gray-100 px-8 py-4 text-lg font-semibold"
|
||||
onClick={handleGetStarted}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Start Free Trial
|
||||
<ArrowRight className="w-5 h-5 ml-2" />
|
||||
</Button>
|
||||
<Button
|
||||
size="lg"
|
||||
variant="outline"
|
||||
className="border-white text-white hover:bg-white/10 px-8 py-4 text-lg"
|
||||
onClick={handleRequestDemo}
|
||||
>
|
||||
Schedule Demo
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="bg-gray-900 text-white py-12">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="grid md:grid-cols-4 gap-8">
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-8 h-8 bg-gradient-to-br from-blue-600 to-purple-600 rounded-lg flex items-center justify-center">
|
||||
<BarChart3 className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<span className="text-xl font-bold">LiveDash</span>
|
||||
</div>
|
||||
<p className="text-gray-400">
|
||||
AI-powered customer conversation analytics for modern teams.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="font-semibold mb-4">Product</h3>
|
||||
<ul className="space-y-2 text-gray-400">
|
||||
<li><a href="#" className="hover:text-white transition-colors">Features</a></li>
|
||||
<li><a href="#" className="hover:text-white transition-colors">Pricing</a></li>
|
||||
<li><a href="#" className="hover:text-white transition-colors">API</a></li>
|
||||
<li><a href="#" className="hover:text-white transition-colors">Integrations</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="font-semibold mb-4">Company</h3>
|
||||
<ul className="space-y-2 text-gray-400">
|
||||
<li><a href="#" className="hover:text-white transition-colors">About</a></li>
|
||||
<li><a href="#" className="hover:text-white transition-colors">Blog</a></li>
|
||||
<li><a href="#" className="hover:text-white transition-colors">Careers</a></li>
|
||||
<li><a href="#" className="hover:text-white transition-colors">Contact</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="font-semibold mb-4">Support</h3>
|
||||
<ul className="space-y-2 text-gray-400">
|
||||
<li><a href="#" className="hover:text-white transition-colors">Documentation</a></li>
|
||||
<li><a href="#" className="hover:text-white transition-colors">Help Center</a></li>
|
||||
<li><a href="#" className="hover:text-white transition-colors">Privacy</a></li>
|
||||
<li><a href="#" className="hover:text-white transition-colors">Terms</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-800 mt-12 pt-8 text-center text-gray-400">
|
||||
<p>© 2024 Notso AI. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
222
app/platform/dashboard/page.tsx
Normal file
222
app/platform/dashboard/page.tsx
Normal file
@ -0,0 +1,222 @@
|
||||
"use client";
|
||||
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Building2,
|
||||
Users,
|
||||
Database,
|
||||
Activity,
|
||||
Plus,
|
||||
Settings,
|
||||
BarChart3
|
||||
} from "lucide-react";
|
||||
|
||||
interface Company {
|
||||
id: string;
|
||||
name: string;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
_count: {
|
||||
users: number;
|
||||
sessions: number;
|
||||
imports: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface DashboardData {
|
||||
companies: Company[];
|
||||
pagination: {
|
||||
total: number;
|
||||
pages: number;
|
||||
};
|
||||
}
|
||||
|
||||
export default function PlatformDashboard() {
|
||||
const { data: session, status } = useSession();
|
||||
const router = useRouter();
|
||||
const [dashboardData, setDashboardData] = useState<DashboardData | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (status === "loading") return;
|
||||
|
||||
if (!session?.user?.isPlatformUser) {
|
||||
router.push("/platform/login");
|
||||
return;
|
||||
}
|
||||
|
||||
fetchDashboardData();
|
||||
}, [session, status, router]);
|
||||
|
||||
const fetchDashboardData = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/platform/companies");
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setDashboardData(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch dashboard data:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadgeVariant = (status: string) => {
|
||||
switch (status) {
|
||||
case "ACTIVE": return "default";
|
||||
case "TRIAL": return "secondary";
|
||||
case "SUSPENDED": return "destructive";
|
||||
case "ARCHIVED": return "outline";
|
||||
default: return "default";
|
||||
}
|
||||
};
|
||||
|
||||
if (status === "loading" || isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="text-center">Loading platform dashboard...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!session?.user?.isPlatformUser) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const totalCompanies = dashboardData?.pagination?.total || 0;
|
||||
const totalUsers = dashboardData?.companies?.reduce((sum, company) => sum + company._count.users, 0) || 0;
|
||||
const totalSessions = dashboardData?.companies?.reduce((sum, company) => sum + company._count.sessions, 0) || 0;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||
<div className="border-b bg-white dark:bg-gray-800">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between items-center py-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
Platform Dashboard
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Welcome back, {session.user.name || session.user.email}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
<Button variant="outline" size="sm">
|
||||
<Settings className="w-4 h-4 mr-2" />
|
||||
Settings
|
||||
</Button>
|
||||
<Button size="sm">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Add Company
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{/* Stats Overview */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Companies</CardTitle>
|
||||
<Building2 className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{totalCompanies}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Users</CardTitle>
|
||||
<Users className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{totalUsers}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Sessions</CardTitle>
|
||||
<Database className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{totalSessions}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Active Companies</CardTitle>
|
||||
<Activity className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{dashboardData?.companies?.filter(c => c.status === "ACTIVE").length || 0}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Companies List */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Building2 className="w-5 h-5" />
|
||||
Companies
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{dashboardData?.companies?.map((company) => (
|
||||
<div
|
||||
key={company.id}
|
||||
className="flex items-center justify-between p-4 border rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h3 className="font-semibold">{company.name}</h3>
|
||||
<Badge variant={getStatusBadgeVariant(company.status)}>
|
||||
{company.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-6 text-sm text-muted-foreground">
|
||||
<span>{company._count.users} users</span>
|
||||
<span>{company._count.sessions} sessions</span>
|
||||
<span>{company._count.imports} imports</span>
|
||||
<span>Created {new Date(company.createdAt).toLocaleDateString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm">
|
||||
<BarChart3 className="w-4 h-4 mr-2" />
|
||||
Analytics
|
||||
</Button>
|
||||
<Button variant="outline" size="sm">
|
||||
<Settings className="w-4 h-4 mr-2" />
|
||||
Manage
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{!dashboardData?.companies?.length && (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
No companies found. Create your first company to get started.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
15
app/platform/layout.tsx
Normal file
15
app/platform/layout.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { SessionProvider } from "next-auth/react";
|
||||
|
||||
export default function PlatformLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<SessionProvider>
|
||||
{children}
|
||||
</SessionProvider>
|
||||
);
|
||||
}
|
||||
103
app/platform/login/page.tsx
Normal file
103
app/platform/login/page.tsx
Normal file
@ -0,0 +1,103 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { signIn, getSession } from "next-auth/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
|
||||
export default function PlatformLoginPage() {
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const router = useRouter();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
setError("");
|
||||
|
||||
try {
|
||||
const result = await signIn("credentials", {
|
||||
email,
|
||||
password,
|
||||
redirect: false,
|
||||
callbackUrl: "/platform/dashboard",
|
||||
});
|
||||
|
||||
if (result?.error) {
|
||||
setError("Invalid credentials");
|
||||
} else {
|
||||
// Verify the session has platform access
|
||||
const session = await getSession();
|
||||
if (session?.user?.isPlatformUser) {
|
||||
router.push("/platform/dashboard");
|
||||
} else {
|
||||
setError("Platform access required");
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
setError("An error occurred during login");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-2xl font-bold">Platform Login</CardTitle>
|
||||
<p className="text-muted-foreground">
|
||||
Sign in to the Notso AI platform management dashboard
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? "Signing in..." : "Sign In"}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -14,6 +14,7 @@
|
||||
"prisma:generate": "prisma generate",
|
||||
"prisma:migrate": "prisma migrate dev",
|
||||
"prisma:seed": "tsx prisma/seed.ts",
|
||||
"prisma:seed:platform": "tsx prisma/seed-platform.ts",
|
||||
"prisma:push": "prisma db push",
|
||||
"prisma:push:force": "prisma db push --force-reset",
|
||||
"prisma:studio": "prisma studio",
|
||||
|
||||
@ -9,6 +9,22 @@ datasource db {
|
||||
directUrl = env("DATABASE_URL_DIRECT")
|
||||
}
|
||||
|
||||
/// *
|
||||
/// * PLATFORM USER (super-admin for Notso AI)
|
||||
/// * Platform-level users who can manage companies and platform-wide settings
|
||||
/// * Separate from Company users for platform management isolation
|
||||
model PlatformUser {
|
||||
id String @id @default(uuid())
|
||||
email String @unique @db.VarChar(255) /// Platform user email address
|
||||
password String @db.VarChar(255) /// Hashed password for platform authentication
|
||||
role PlatformUserRole @default(ADMIN) /// Platform permission level
|
||||
name String @db.VarChar(255) /// Display name for platform user
|
||||
createdAt DateTime @default(now()) @db.Timestamptz(6)
|
||||
updatedAt DateTime @updatedAt @db.Timestamptz(6)
|
||||
|
||||
@@index([email])
|
||||
}
|
||||
|
||||
/// *
|
||||
/// * COMPANY (multi-tenant root)
|
||||
/// * Root entity for multi-tenant architecture
|
||||
@ -16,6 +32,7 @@ datasource db {
|
||||
model Company {
|
||||
id String @id @default(uuid())
|
||||
name String @db.VarChar(255) /// Company name for display and filtering
|
||||
status CompanyStatus @default(ACTIVE) /// Company status for suspension/activation
|
||||
csvUrl String @db.Text /// URL endpoint for CSV data import
|
||||
csvUsername String? @db.VarChar(255) /// Optional HTTP auth username for CSV endpoint
|
||||
csvPassword String? @db.VarChar(255) /// Optional HTTP auth password for CSV endpoint
|
||||
@ -28,6 +45,7 @@ model Company {
|
||||
users User[] @relation("CompanyUsers") /// Users belonging to this company
|
||||
|
||||
@@index([name])
|
||||
@@index([status])
|
||||
}
|
||||
|
||||
/// *
|
||||
@ -269,6 +287,13 @@ model CompanyAIModel {
|
||||
/// * ENUMS – typed constants for better data integrity
|
||||
///
|
||||
|
||||
/// Platform-level user roles for Notso AI team
|
||||
enum PlatformUserRole {
|
||||
SUPER_ADMIN /// Full platform access, can create/suspend companies
|
||||
ADMIN /// Platform administration, company management
|
||||
SUPPORT /// Customer support access, read-only company access
|
||||
}
|
||||
|
||||
/// User permission levels within a company
|
||||
enum UserRole {
|
||||
ADMIN /// Full access to company data and settings
|
||||
@ -276,6 +301,14 @@ enum UserRole {
|
||||
AUDITOR /// Read-only access for compliance and auditing
|
||||
}
|
||||
|
||||
/// Company operational status
|
||||
enum CompanyStatus {
|
||||
ACTIVE /// Company is operational and can access all features
|
||||
SUSPENDED /// Company access is temporarily disabled
|
||||
TRIAL /// Company is in trial period with potential limitations
|
||||
ARCHIVED /// Company is archived and data is read-only
|
||||
}
|
||||
|
||||
/// AI-determined sentiment categories for sessions
|
||||
enum SentimentCategory {
|
||||
POSITIVE /// Customer expressed satisfaction or positive emotions
|
||||
|
||||
63
prisma/seed-platform.ts
Normal file
63
prisma/seed-platform.ts
Normal file
@ -0,0 +1,63 @@
|
||||
import { PrismaClient, PlatformUserRole } from "@prisma/client";
|
||||
import bcrypt from "bcryptjs";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
console.log("🚀 Seeding platform users for Notso AI...");
|
||||
|
||||
// Create initial platform admin user
|
||||
const adminPassword = await bcrypt.hash("NotsoAI2024!Admin", 12);
|
||||
|
||||
const admin = await prisma.platformUser.upsert({
|
||||
where: { email: "admin@notso.ai" },
|
||||
update: {},
|
||||
create: {
|
||||
email: "admin@notso.ai",
|
||||
password: adminPassword,
|
||||
name: "Platform Administrator",
|
||||
role: PlatformUserRole.SUPER_ADMIN,
|
||||
},
|
||||
});
|
||||
|
||||
console.log("✅ Created platform super admin:", admin.email);
|
||||
|
||||
// Create support user
|
||||
const supportPassword = await bcrypt.hash("NotsoAI2024!Support", 12);
|
||||
|
||||
const support = await prisma.platformUser.upsert({
|
||||
where: { email: "support@notso.ai" },
|
||||
update: {},
|
||||
create: {
|
||||
email: "support@notso.ai",
|
||||
password: supportPassword,
|
||||
name: "Support Team",
|
||||
role: PlatformUserRole.SUPPORT,
|
||||
},
|
||||
});
|
||||
|
||||
console.log("✅ Created platform support user:", support.email);
|
||||
|
||||
console.log("\n🔑 Platform Login Credentials:");
|
||||
console.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
|
||||
console.log("Super Admin:");
|
||||
console.log(" Email: admin@notso.ai");
|
||||
console.log(" Password: NotsoAI2024!Admin");
|
||||
console.log("");
|
||||
console.log("Support:");
|
||||
console.log(" Email: support@notso.ai");
|
||||
console.log(" Password: NotsoAI2024!Support");
|
||||
console.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
|
||||
console.log("\n🌐 Platform Access:");
|
||||
console.log(" Login: http://localhost:3000/platform/login");
|
||||
console.log(" Dashboard: http://localhost:3000/platform/dashboard");
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error("❌ Platform seeding failed:", e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
@ -32,10 +32,10 @@ test.describe("Theme Switching Visual Tests", () => {
|
||||
|
||||
test("User Management page should render correctly in light theme", async ({ page }) => {
|
||||
await page.goto("/dashboard/users");
|
||||
|
||||
|
||||
// Wait for content to load
|
||||
await page.waitForSelector('[data-testid="user-management-page"]', { timeout: 10000 });
|
||||
|
||||
|
||||
// Ensure light theme is active
|
||||
await page.evaluate(() => {
|
||||
document.documentElement.classList.remove("dark");
|
||||
@ -54,10 +54,10 @@ test.describe("Theme Switching Visual Tests", () => {
|
||||
|
||||
test("User Management page should render correctly in dark theme", async ({ page }) => {
|
||||
await page.goto("/dashboard/users");
|
||||
|
||||
|
||||
// Wait for content to load
|
||||
await page.waitForSelector('[data-testid="user-management-page"]', { timeout: 10000 });
|
||||
|
||||
|
||||
// Enable dark theme
|
||||
await page.evaluate(() => {
|
||||
document.documentElement.classList.remove("light");
|
||||
@ -76,13 +76,13 @@ test.describe("Theme Switching Visual Tests", () => {
|
||||
|
||||
test("Theme toggle should work correctly", async ({ page }) => {
|
||||
await page.goto("/dashboard/users");
|
||||
|
||||
|
||||
// Wait for content to load
|
||||
await page.waitForSelector('[data-testid="user-management-page"]', { timeout: 10000 });
|
||||
|
||||
// Find theme toggle button (assuming it exists in the layout)
|
||||
const themeToggle = page.locator('[data-testid="theme-toggle"]').first();
|
||||
|
||||
|
||||
if (await themeToggle.count() > 0) {
|
||||
// Start with light theme
|
||||
await page.evaluate(() => {
|
||||
@ -157,7 +157,7 @@ test.describe("Theme Switching Visual Tests", () => {
|
||||
animations: "disabled",
|
||||
});
|
||||
|
||||
// Dark theme table
|
||||
// Dark theme table
|
||||
await page.evaluate(() => {
|
||||
document.documentElement.classList.remove("light");
|
||||
document.documentElement.classList.add("dark");
|
||||
@ -248,7 +248,7 @@ test.describe("Theme Switching Visual Tests", () => {
|
||||
|
||||
const emailInput = page.locator('input[type="email"]').first();
|
||||
const submitButton = page.locator('button[type="submit"]').first();
|
||||
|
||||
|
||||
await emailInput.waitFor({ timeout: 5000 });
|
||||
await submitButton.waitFor({ timeout: 5000 });
|
||||
|
||||
@ -373,22 +373,22 @@ test.describe("Theme Switching Visual Tests", () => {
|
||||
|
||||
// Find theme toggle if it exists
|
||||
const themeToggle = page.locator('[data-testid="theme-toggle"]').first();
|
||||
|
||||
|
||||
if (await themeToggle.count() > 0) {
|
||||
// Record video during theme switch
|
||||
await page.video()?.path();
|
||||
|
||||
|
||||
// Toggle theme
|
||||
await themeToggle.click();
|
||||
|
||||
|
||||
// Wait for transition to complete
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
|
||||
// Verify dark theme is applied
|
||||
const isDarkMode = await page.evaluate(() => {
|
||||
return document.documentElement.classList.contains("dark");
|
||||
});
|
||||
|
||||
|
||||
expect(isDarkMode).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
@ -25,7 +25,7 @@ const mockExistingUsers = [
|
||||
},
|
||||
{
|
||||
id: "user-2",
|
||||
email: "admin@example.com",
|
||||
email: "admin@example.com",
|
||||
role: "ADMIN",
|
||||
companyId: "test-company-id",
|
||||
},
|
||||
@ -332,7 +332,7 @@ describe("User Invitation Integration Tests", () => {
|
||||
describe("Email Validation Edge Cases", () => {
|
||||
it("should handle very long email addresses", async () => {
|
||||
const longEmail = "a".repeat(250) + "@example.com";
|
||||
|
||||
|
||||
const { req, res } = createMocks({
|
||||
method: "POST",
|
||||
body: {
|
||||
@ -354,7 +354,7 @@ describe("User Invitation Integration Tests", () => {
|
||||
|
||||
it("should handle special characters in email", async () => {
|
||||
const specialEmail = "test+tag@example-domain.co.uk";
|
||||
|
||||
|
||||
const { req, res } = createMocks({
|
||||
method: "POST",
|
||||
body: {
|
||||
@ -424,7 +424,7 @@ describe("User Invitation Integration Tests", () => {
|
||||
it("should handle multiple rapid invitations", async () => {
|
||||
const emails = [
|
||||
"user1@example.com",
|
||||
"user2@example.com",
|
||||
"user2@example.com",
|
||||
"user3@example.com",
|
||||
"user4@example.com",
|
||||
"user5@example.com",
|
||||
|
||||
@ -56,7 +56,7 @@ describe("Accessibility Tests", () => {
|
||||
);
|
||||
|
||||
await screen.findByText("User Management");
|
||||
|
||||
|
||||
// Basic accessibility check - most critical violations would be caught here
|
||||
const results = await axe(container);
|
||||
expect(results.violations.length).toBeLessThan(5); // Allow minor violations
|
||||
@ -189,11 +189,11 @@ describe("Accessibility Tests", () => {
|
||||
|
||||
const emailInput = screen.getByLabelText("Email");
|
||||
const submitButton = screen.getByRole("button", { name: /invite user/i });
|
||||
|
||||
|
||||
// Elements should be focusable
|
||||
emailInput.focus();
|
||||
expect(emailInput).toHaveFocus();
|
||||
|
||||
|
||||
submitButton.focus();
|
||||
expect(submitButton).toHaveFocus();
|
||||
});
|
||||
|
||||
@ -99,7 +99,7 @@ describe("Format Enums Utility", () => {
|
||||
it("should be an alias for formatEnumValue", () => {
|
||||
const testValues = [
|
||||
"SALARY_COMPENSATION",
|
||||
"SCHEDULE_HOURS",
|
||||
"SCHEDULE_HOURS",
|
||||
"UNKNOWN_ENUM",
|
||||
null,
|
||||
undefined,
|
||||
@ -129,7 +129,7 @@ describe("Format Enums Utility", () => {
|
||||
it("should handle very long enum values", () => {
|
||||
const longEnum = "A".repeat(100) + "_" + "B".repeat(100);
|
||||
const result = formatEnumValue(longEnum);
|
||||
|
||||
|
||||
expect(result).toBeTruthy();
|
||||
expect(result?.length).toBeGreaterThan(200);
|
||||
expect(result?.includes(" ")).toBeTruthy();
|
||||
@ -150,16 +150,16 @@ describe("Format Enums Utility", () => {
|
||||
it("should be performant with many calls", () => {
|
||||
const testEnum = "SALARY_COMPENSATION";
|
||||
const iterations = 1000;
|
||||
|
||||
|
||||
const startTime = performance.now();
|
||||
|
||||
|
||||
for (let i = 0; i < iterations; i++) {
|
||||
formatEnumValue(testEnum);
|
||||
}
|
||||
|
||||
|
||||
const endTime = performance.now();
|
||||
const duration = endTime - startTime;
|
||||
|
||||
|
||||
// Should complete 1000 calls in reasonable time (less than 100ms)
|
||||
expect(duration).toBeLessThan(100);
|
||||
});
|
||||
@ -208,7 +208,7 @@ describe("Format Enums Utility", () => {
|
||||
it("should provide readable text for badges and labels", () => {
|
||||
const badgeValues = [
|
||||
"ADMIN",
|
||||
"USER",
|
||||
"USER",
|
||||
"AUDITOR",
|
||||
"UNRECOGNIZED_OTHER",
|
||||
];
|
||||
@ -249,7 +249,7 @@ describe("Format Enums Utility", () => {
|
||||
// Future enum values should still be formatted reasonably
|
||||
const futureEnums = [
|
||||
"REMOTE_WORK_POLICY",
|
||||
"SUSTAINABILITY_INITIATIVES",
|
||||
"SUSTAINABILITY_INITIATIVES",
|
||||
"DIVERSITY_INCLUSION",
|
||||
"MENTAL_HEALTH_SUPPORT",
|
||||
];
|
||||
|
||||
@ -52,7 +52,7 @@ describe("Keyboard Navigation Tests", () => {
|
||||
|
||||
roleSelect.focus();
|
||||
expect(roleSelect).toBeInTheDocument();
|
||||
|
||||
|
||||
submitButton.focus();
|
||||
expect(document.activeElement).toBe(submitButton);
|
||||
});
|
||||
@ -84,7 +84,7 @@ describe("Keyboard Navigation Tests", () => {
|
||||
|
||||
// Submit with Enter key
|
||||
fireEvent.keyDown(submitButton, { key: "Enter" });
|
||||
|
||||
|
||||
// Form should be submitted (fetch called for initial load + submission)
|
||||
expect(global.fetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
@ -117,7 +117,7 @@ describe("Keyboard Navigation Tests", () => {
|
||||
// Activate with Space key
|
||||
submitButton.focus();
|
||||
fireEvent.keyDown(submitButton, { key: " " });
|
||||
|
||||
|
||||
// Should trigger form submission (fetch called for initial load + submission)
|
||||
expect(global.fetch).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
@ -153,7 +153,7 @@ describe("Keyboard Navigation Tests", () => {
|
||||
|
||||
// Press Escape
|
||||
fireEvent.keyDown(emailInput, { key: "Escape" });
|
||||
|
||||
|
||||
// Field should not be cleared by Escape (browser default behavior)
|
||||
// But it should not cause any errors
|
||||
expect(emailInput.value).toBe("test@example.com");
|
||||
@ -173,7 +173,7 @@ describe("Keyboard Navigation Tests", () => {
|
||||
// Arrow keys should work (implementation depends on Select component)
|
||||
fireEvent.keyDown(roleSelect, { key: "ArrowDown" });
|
||||
fireEvent.keyDown(roleSelect, { key: "ArrowUp" });
|
||||
|
||||
|
||||
// Should not throw errors
|
||||
expect(roleSelect).toBeInTheDocument();
|
||||
});
|
||||
@ -292,7 +292,7 @@ describe("Keyboard Navigation Tests", () => {
|
||||
);
|
||||
|
||||
const chart = screen.getByRole("img", { name: /test chart/i });
|
||||
|
||||
|
||||
// Chart should be focusable
|
||||
chart.focus();
|
||||
expect(chart).toHaveFocus();
|
||||
@ -311,7 +311,7 @@ describe("Keyboard Navigation Tests", () => {
|
||||
);
|
||||
|
||||
const chart = screen.getByRole("img", { name: /test chart/i });
|
||||
|
||||
|
||||
chart.focus();
|
||||
|
||||
// Test keyboard interactions
|
||||
@ -395,7 +395,7 @@ describe("Keyboard Navigation Tests", () => {
|
||||
// Should handle focus on disabled elements gracefully
|
||||
submitButton.focus();
|
||||
fireEvent.keyDown(submitButton, { key: "Enter" });
|
||||
|
||||
|
||||
// Should not cause errors
|
||||
});
|
||||
|
||||
@ -515,7 +515,7 @@ describe("Keyboard Navigation Tests", () => {
|
||||
await screen.findByText("User Management");
|
||||
|
||||
const emailInput = screen.getByLabelText("Email");
|
||||
|
||||
|
||||
// Focus should still work in high contrast mode
|
||||
emailInput.focus();
|
||||
expect(emailInput).toHaveFocus();
|
||||
|
||||
@ -263,7 +263,7 @@ describe("UserManagementPage", () => {
|
||||
|
||||
await waitFor(() => {
|
||||
const emailInput = screen.getByLabelText("Email") as HTMLInputElement;
|
||||
|
||||
|
||||
fireEvent.change(emailInput, { target: { value: "invalid-email" } });
|
||||
fireEvent.blur(emailInput);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user