gaurv007 commited on
Commit
89ccd89
Β·
verified Β·
1 Parent(s): 922c4c8

Build all missing features: PDF/DOCX upload, team system (5 seats, invites, shared dashboard), API keys (generate/revoke/limits), custom clause rules (CRUD + regex)

Browse files
web/app/api/api-keys/route.ts ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { createClient } from "@/lib/supabase/server";
3
+ import crypto from "crypto";
4
+
5
+ // GET β€” list user's API keys
6
+ export async function GET() {
7
+ const supabase = await createClient();
8
+ const { data: { user } } = await supabase.auth.getUser();
9
+ if (!user) return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
10
+
11
+ const { data: profile } = await supabase.from("profiles").select("plan, team_id").eq("id", user.id).single();
12
+ if (profile?.plan === "free") return NextResponse.json({ error: "API access requires Pro or Team plan" }, { status: 403 });
13
+
14
+ const { data: keys } = await supabase.from("api_keys")
15
+ .select("id, name, key_prefix, calls_this_month, calls_limit, is_active, last_used_at, created_at")
16
+ .eq("user_id", user.id)
17
+ .order("created_at", { ascending: false });
18
+
19
+ return NextResponse.json({ keys: keys || [] });
20
+ }
21
+
22
+ // POST β€” create new API key
23
+ export async function POST(req: NextRequest) {
24
+ const supabase = await createClient();
25
+ const { data: { user } } = await supabase.auth.getUser();
26
+ if (!user) return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
27
+
28
+ const { data: profile } = await supabase.from("profiles").select("plan, team_id").eq("id", user.id).single();
29
+ if (profile?.plan === "free") return NextResponse.json({ error: "API access requires Pro or Team plan" }, { status: 403 });
30
+
31
+ const { name } = await req.json();
32
+
33
+ // Generate key: cg_live_ + 32 random hex chars
34
+ const rawKey = "cg_live_" + crypto.randomBytes(24).toString("hex");
35
+ const keyHash = crypto.createHash("sha256").update(rawKey).digest("hex");
36
+ const keyPrefix = rawKey.substring(0, 16) + "...";
37
+
38
+ const callsLimit = profile?.plan === "team" ? 10000 : 1000;
39
+
40
+ const { error } = await supabase.from("api_keys").insert({
41
+ user_id: user.id,
42
+ team_id: profile?.team_id || null,
43
+ name: name || "Default",
44
+ key_hash: keyHash,
45
+ key_prefix: keyPrefix,
46
+ calls_limit: callsLimit,
47
+ });
48
+
49
+ if (error) return NextResponse.json({ error: error.message }, { status: 500 });
50
+
51
+ // Return the full key ONCE β€” it's never shown again
52
+ return NextResponse.json({ key: rawKey, prefix: keyPrefix, name, calls_limit: callsLimit });
53
+ }
54
+
55
+ // DELETE β€” revoke an API key
56
+ export async function DELETE(req: NextRequest) {
57
+ const supabase = await createClient();
58
+ const { data: { user } } = await supabase.auth.getUser();
59
+ if (!user) return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
60
+
61
+ const { keyId } = await req.json();
62
+
63
+ const { error } = await supabase.from("api_keys")
64
+ .update({ is_active: false })
65
+ .eq("id", keyId)
66
+ .eq("user_id", user.id);
67
+
68
+ if (error) return NextResponse.json({ error: error.message }, { status: 500 });
69
+ return NextResponse.json({ success: true });
70
+ }
web/app/api/custom-rules/route.ts ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { createClient } from "@/lib/supabase/server";
3
+
4
+ // GET β€” list custom rules
5
+ export async function GET() {
6
+ const supabase = await createClient();
7
+ const { data: { user } } = await supabase.auth.getUser();
8
+ if (!user) return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
9
+
10
+ const { data: profile } = await supabase.from("profiles").select("plan, team_id").eq("id", user.id).single();
11
+ if (profile?.plan !== "team") return NextResponse.json({ error: "Custom rules require Team plan" }, { status: 403 });
12
+
13
+ // Fetch user's own rules + team rules
14
+ let query = supabase.from("custom_rules").select("*").order("created_at", { ascending: false });
15
+
16
+ if (profile?.team_id) {
17
+ query = query.or(`user_id.eq.${user.id},team_id.eq.${profile.team_id}`);
18
+ } else {
19
+ query = query.eq("user_id", user.id);
20
+ }
21
+
22
+ const { data: rules } = await query;
23
+ return NextResponse.json({ rules: rules || [] });
24
+ }
25
+
26
+ // POST β€” create a custom rule
27
+ export async function POST(req: NextRequest) {
28
+ const supabase = await createClient();
29
+ const { data: { user } } = await supabase.auth.getUser();
30
+ if (!user) return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
31
+
32
+ const { data: profile } = await supabase.from("profiles").select("plan, team_id").eq("id", user.id).single();
33
+ if (profile?.plan !== "team") return NextResponse.json({ error: "Custom rules require Team plan" }, { status: 403 });
34
+
35
+ const { name, description, pattern, severity, category } = await req.json();
36
+
37
+ if (!name || !pattern || !category) {
38
+ return NextResponse.json({ error: "name, pattern, and category are required" }, { status: 400 });
39
+ }
40
+
41
+ // Validate regex pattern
42
+ try { new RegExp(pattern, "i"); } catch {
43
+ return NextResponse.json({ error: "Invalid regex pattern" }, { status: 400 });
44
+ }
45
+
46
+ const { data: rule, error } = await supabase.from("custom_rules").insert({
47
+ user_id: user.id,
48
+ team_id: profile?.team_id || null,
49
+ name,
50
+ description: description || null,
51
+ pattern,
52
+ severity: severity || "MEDIUM",
53
+ category,
54
+ }).select().single();
55
+
56
+ if (error) return NextResponse.json({ error: error.message }, { status: 500 });
57
+ return NextResponse.json({ rule });
58
+ }
59
+
60
+ // PUT β€” update a rule
61
+ export async function PUT(req: NextRequest) {
62
+ const supabase = await createClient();
63
+ const { data: { user } } = await supabase.auth.getUser();
64
+ if (!user) return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
65
+
66
+ const { id, ...updates } = await req.json();
67
+
68
+ if (updates.pattern) {
69
+ try { new RegExp(updates.pattern, "i"); } catch {
70
+ return NextResponse.json({ error: "Invalid regex pattern" }, { status: 400 });
71
+ }
72
+ }
73
+
74
+ const { error } = await supabase.from("custom_rules")
75
+ .update({ ...updates, updated_at: new Date().toISOString() })
76
+ .eq("id", id)
77
+ .eq("user_id", user.id);
78
+
79
+ if (error) return NextResponse.json({ error: error.message }, { status: 500 });
80
+ return NextResponse.json({ success: true });
81
+ }
82
+
83
+ // DELETE β€” delete a rule
84
+ export async function DELETE(req: NextRequest) {
85
+ const supabase = await createClient();
86
+ const { data: { user } } = await supabase.auth.getUser();
87
+ if (!user) return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
88
+
89
+ const { id } = await req.json();
90
+
91
+ const { error } = await supabase.from("custom_rules").delete().eq("id", id).eq("user_id", user.id);
92
+ if (error) return NextResponse.json({ error: error.message }, { status: 500 });
93
+ return NextResponse.json({ success: true });
94
+ }
web/app/api/parse-upload/route.ts ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from "next/server";
2
+
3
+ export const runtime = "nodejs";
4
+
5
+ export async function POST(req: NextRequest) {
6
+ try {
7
+ const formData = await req.formData();
8
+ const file = formData.get("file") as File | null;
9
+
10
+ if (!file) {
11
+ return NextResponse.json({ error: "No file uploaded" }, { status: 400 });
12
+ }
13
+
14
+ const name = file.name.toLowerCase();
15
+ const buffer = Buffer.from(await file.arrayBuffer());
16
+ let text = "";
17
+
18
+ if (name.endsWith(".txt") || name.endsWith(".md")) {
19
+ text = new TextDecoder().decode(buffer);
20
+ } else if (name.endsWith(".pdf")) {
21
+ // pdf-parse v2
22
+ await import("pdf-parse/worker");
23
+ const { PDFParse } = await import("pdf-parse");
24
+ const parser = new PDFParse({ data: buffer });
25
+ const result = await parser.getText();
26
+ text = result.text;
27
+ await parser.destroy();
28
+ } else if (name.endsWith(".docx")) {
29
+ const mammoth = (await import("mammoth")).default;
30
+ const result = await mammoth.extractRawText({ buffer });
31
+ text = result.value;
32
+ } else {
33
+ return NextResponse.json({ error: "Unsupported file type. Use .pdf, .docx, .txt, or .md" }, { status: 400 });
34
+ }
35
+
36
+ if (!text || text.trim().length < 30) {
37
+ return NextResponse.json({ error: "Could not extract enough text from this file." }, { status: 400 });
38
+ }
39
+
40
+ return NextResponse.json({ text: text.trim(), filename: file.name, size: file.size });
41
+ } catch (error: any) {
42
+ console.error("File parse error:", error);
43
+ return NextResponse.json({ error: "Failed to parse file: " + (error.message || "Unknown error") }, { status: 500 });
44
+ }
45
+ }
web/app/api/teams/route.ts ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { createClient } from "@/lib/supabase/server";
3
+ import crypto from "crypto";
4
+
5
+ // GET β€” fetch current team + members
6
+ export async function GET() {
7
+ const supabase = await createClient();
8
+ const { data: { user } } = await supabase.auth.getUser();
9
+ if (!user) return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
10
+
11
+ const { data: profile } = await supabase.from("profiles").select("team_id, plan").eq("id", user.id).single();
12
+ if (!profile?.team_id) return NextResponse.json({ team: null, members: [], invites: [] });
13
+
14
+ const { data: team } = await supabase.from("teams").select("*").eq("id", profile.team_id).single();
15
+ const { data: members } = await supabase.from("profiles").select("id, email, full_name, avatar_url").eq("team_id", profile.team_id);
16
+ const { data: invites } = await supabase.from("team_invites").select("*").eq("team_id", profile.team_id).eq("status", "pending");
17
+
18
+ return NextResponse.json({ team, members: members || [], invites: invites || [] });
19
+ }
20
+
21
+ // POST β€” create team or invite member
22
+ export async function POST(req: NextRequest) {
23
+ const supabase = await createClient();
24
+ const { data: { user } } = await supabase.auth.getUser();
25
+ if (!user) return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
26
+
27
+ const body = await req.json();
28
+
29
+ // Create team
30
+ if (body.action === "create") {
31
+ const { data: profile } = await supabase.from("profiles").select("plan, team_id").eq("id", user.id).single();
32
+ if (profile?.plan !== "team") return NextResponse.json({ error: "Team plan required" }, { status: 403 });
33
+ if (profile?.team_id) return NextResponse.json({ error: "Already in a team" }, { status: 400 });
34
+
35
+ const { data: team, error } = await supabase.from("teams").insert({
36
+ name: body.name || "My Team", owner_id: user.id,
37
+ }).select().single();
38
+
39
+ if (error) return NextResponse.json({ error: error.message }, { status: 500 });
40
+
41
+ await supabase.from("profiles").update({ team_id: team.id }).eq("id", user.id);
42
+ return NextResponse.json({ team });
43
+ }
44
+
45
+ // Invite member
46
+ if (body.action === "invite") {
47
+ const { data: profile } = await supabase.from("profiles").select("team_id").eq("id", user.id).single();
48
+ if (!profile?.team_id) return NextResponse.json({ error: "No team" }, { status: 400 });
49
+
50
+ // Check seat limit
51
+ const { count } = await supabase.from("profiles").select("id", { count: "exact" }).eq("team_id", profile.team_id);
52
+ const { data: team } = await supabase.from("teams").select("max_seats").eq("id", profile.team_id).single();
53
+ if ((count || 0) >= (team?.max_seats || 5)) return NextResponse.json({ error: "Team is full (max 5 seats)" }, { status: 400 });
54
+
55
+ const { error } = await supabase.from("team_invites").insert({
56
+ team_id: profile.team_id, email: body.email, invited_by: user.id, role: body.role || "member",
57
+ });
58
+
59
+ if (error) return NextResponse.json({ error: error.message }, { status: 500 });
60
+ return NextResponse.json({ success: true });
61
+ }
62
+
63
+ // Accept invite
64
+ if (body.action === "accept") {
65
+ const { data: invite } = await supabase.from("team_invites")
66
+ .select("*").eq("id", body.invite_id).eq("email", user.email).eq("status", "pending").single();
67
+
68
+ if (!invite) return NextResponse.json({ error: "Invite not found" }, { status: 404 });
69
+
70
+ await supabase.from("profiles").update({ team_id: invite.team_id }).eq("id", user.id);
71
+ await supabase.from("team_invites").update({ status: "accepted" }).eq("id", invite.id);
72
+ return NextResponse.json({ success: true });
73
+ }
74
+
75
+ return NextResponse.json({ error: "Invalid action" }, { status: 400 });
76
+ }
77
+
78
+ // DELETE β€” remove member or leave team
79
+ export async function DELETE(req: NextRequest) {
80
+ const supabase = await createClient();
81
+ const { data: { user } } = await supabase.auth.getUser();
82
+ if (!user) return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
83
+
84
+ const { memberId } = await req.json();
85
+
86
+ if (memberId === user.id) {
87
+ // Leave team
88
+ await supabase.from("profiles").update({ team_id: null }).eq("id", user.id);
89
+ } else {
90
+ // Remove member (owner only)
91
+ const { data: profile } = await supabase.from("profiles").select("team_id").eq("id", user.id).single();
92
+ const { data: team } = await supabase.from("teams").select("owner_id").eq("id", profile?.team_id).single();
93
+ if (team?.owner_id !== user.id) return NextResponse.json({ error: "Only owner can remove members" }, { status: 403 });
94
+
95
+ await supabase.from("profiles").update({ team_id: null }).eq("id", memberId);
96
+ }
97
+
98
+ return NextResponse.json({ success: true });
99
+ }
web/app/dashboard-pages/team/page.tsx ADDED
@@ -0,0 +1,142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { createClient } from "@/lib/supabase/server";
2
+ import { redirect } from "next/navigation";
3
+ import Link from "next/link";
4
+ import { ArrowLeft, Users, UserPlus, Crown, Mail, Key, ScrollText, Trash2, Shield } from "lucide-react";
5
+
6
+ export default async function TeamPage() {
7
+ const supabase = await createClient();
8
+ const { data: { user } } = await supabase.auth.getUser();
9
+ if (!user) redirect("/auth/login");
10
+
11
+ const { data: profile } = await supabase.from("profiles").select("plan, team_id").eq("id", user.id).single();
12
+
13
+ if (profile?.plan !== "team") {
14
+ return (
15
+ <div className="min-h-screen bg-white flex items-center justify-center">
16
+ <div className="text-center max-w-sm">
17
+ <Users className="w-10 h-10 text-zinc-300 mx-auto mb-4" />
18
+ <h2 className="text-xl font-semibold">Team features</h2>
19
+ <p className="mt-2 text-sm text-zinc-500">Upgrade to the Team plan to invite members, share dashboards, and manage API keys.</p>
20
+ <Link href="/#pricing" className="mt-4 inline-block bg-zinc-900 text-white px-5 py-2.5 rounded-lg text-sm font-medium hover:bg-zinc-800">View plans</Link>
21
+ </div>
22
+ </div>
23
+ );
24
+ }
25
+
26
+ // Fetch team data
27
+ let team = null, members: any[] = [], invites: any[] = [], analyses: any[] = [];
28
+
29
+ if (profile?.team_id) {
30
+ const { data: t } = await supabase.from("teams").select("*").eq("id", profile.team_id).single();
31
+ team = t;
32
+ const { data: m } = await supabase.from("profiles").select("id, email, full_name, avatar_url, analyses_this_month").eq("team_id", profile.team_id);
33
+ members = m || [];
34
+ const { data: inv } = await supabase.from("team_invites").select("*").eq("team_id", profile.team_id).eq("status", "pending");
35
+ invites = inv || [];
36
+ const { data: a } = await supabase.from("analyses").select("id, source_url, risk_score, grade, flagged_count, created_at, user_id")
37
+ .eq("team_id", profile.team_id).order("created_at", { ascending: false }).limit(20);
38
+ analyses = a || [];
39
+ }
40
+
41
+ const isOwner = team?.owner_id === user.id;
42
+
43
+ return (
44
+ <div className="min-h-screen bg-white">
45
+ <div className="max-w-4xl mx-auto px-5 py-12">
46
+ <div className="mb-8">
47
+ <Link href="/dashboard-pages/dashboard" className="inline-flex items-center gap-1.5 text-sm text-zinc-400 hover:text-zinc-600">
48
+ <ArrowLeft className="w-3.5 h-3.5" /> Dashboard
49
+ </Link>
50
+ <h1 className="mt-4 text-2xl font-semibold tracking-tight flex items-center gap-2">
51
+ <Users className="w-6 h-6 text-zinc-400" />
52
+ {team?.name || "Team"}
53
+ </h1>
54
+ </div>
55
+
56
+ {!team ? (
57
+ <div className="border border-zinc-200 rounded-xl p-8 text-center">
58
+ <Users className="w-10 h-10 text-zinc-300 mx-auto mb-3" />
59
+ <h3 className="font-semibold">Create your team</h3>
60
+ <p className="mt-1 text-sm text-zinc-500">Set up a team to invite members and share scans.</p>
61
+ <form action="/api/teams" method="POST" className="mt-4">
62
+ <input type="hidden" name="action" value="create" />
63
+ <button type="submit" className="bg-zinc-900 text-white px-5 py-2.5 rounded-lg text-sm font-medium hover:bg-zinc-800">Create team</button>
64
+ </form>
65
+ </div>
66
+ ) : (
67
+ <div className="space-y-8">
68
+ {/* Members */}
69
+ <section>
70
+ <div className="flex items-center justify-between mb-3">
71
+ <div className="flex items-center gap-2">
72
+ <Users className="w-4 h-4 text-zinc-400" />
73
+ <h2 className="text-sm font-medium text-zinc-500 uppercase tracking-wider">Members ({members.length}/{team.max_seats})</h2>
74
+ </div>
75
+ </div>
76
+ <div className="border border-zinc-200 rounded-xl divide-y divide-zinc-100">
77
+ {members.map((m) => (
78
+ <div key={m.id} className="px-5 py-3.5 flex items-center justify-between">
79
+ <div className="flex items-center gap-3">
80
+ <div className="w-8 h-8 rounded-full bg-zinc-100 flex items-center justify-center text-xs font-medium text-zinc-600">
81
+ {(m.full_name || m.email || "?")[0].toUpperCase()}
82
+ </div>
83
+ <div>
84
+ <p className="text-sm font-medium flex items-center gap-1.5">
85
+ {m.full_name || m.email}
86
+ {m.id === team.owner_id && <Crown className="w-3.5 h-3.5 text-amber-500" />}
87
+ </p>
88
+ <p className="text-xs text-zinc-400">{m.email} Β· {m.analyses_this_month || 0} scans this month</p>
89
+ </div>
90
+ </div>
91
+ </div>
92
+ ))}
93
+ {invites.map((inv) => (
94
+ <div key={inv.id} className="px-5 py-3.5 flex items-center justify-between opacity-60">
95
+ <div className="flex items-center gap-3">
96
+ <div className="w-8 h-8 rounded-full bg-zinc-50 border border-dashed border-zinc-300 flex items-center justify-center">
97
+ <Mail className="w-3.5 h-3.5 text-zinc-400" />
98
+ </div>
99
+ <div>
100
+ <p className="text-sm">{inv.email}</p>
101
+ <p className="text-xs text-zinc-400">Invite pending</p>
102
+ </div>
103
+ </div>
104
+ </div>
105
+ ))}
106
+ </div>
107
+ </section>
108
+
109
+ {/* Recent team scans */}
110
+ <section>
111
+ <div className="flex items-center gap-2 mb-3">
112
+ <Shield className="w-4 h-4 text-zinc-400" />
113
+ <h2 className="text-sm font-medium text-zinc-500 uppercase tracking-wider">Recent team scans</h2>
114
+ </div>
115
+ <div className="border border-zinc-200 rounded-xl divide-y divide-zinc-100">
116
+ {analyses.length === 0 ? (
117
+ <div className="px-5 py-8 text-center text-sm text-zinc-400">No scans yet.</div>
118
+ ) : analyses.slice(0, 10).map((a) => {
119
+ const member = members.find(m => m.id === a.user_id);
120
+ return (
121
+ <div key={a.id} className="px-5 py-3 flex items-center justify-between">
122
+ <div>
123
+ <p className="text-sm text-zinc-700 truncate max-w-xs">{a.source_url || "Manual scan"}</p>
124
+ <p className="text-xs text-zinc-400 mt-0.5">
125
+ {member?.full_name || member?.email || "Unknown"} Β· {new Date(a.created_at).toLocaleDateString()}
126
+ </p>
127
+ </div>
128
+ <span className={`text-xs font-semibold px-2.5 py-1 rounded-md ${
129
+ a.grade === "F" || a.grade === "D" ? "bg-red-50 text-red-700" :
130
+ a.grade === "C" ? "bg-amber-50 text-amber-700" : "bg-emerald-50 text-emerald-700"
131
+ }`}>{a.grade} Β· {a.risk_score}</span>
132
+ </div>
133
+ );
134
+ })}
135
+ </div>
136
+ </section>
137
+ </div>
138
+ )}
139
+ </div>
140
+ </div>
141
+ );
142
+ }
web/lib/supabase/schema.sql CHANGED
@@ -1,6 +1,6 @@
1
- -- ClauseGuard β€” Supabase Database Schema (Razorpay)
2
 
3
- -- Profiles
4
  CREATE TABLE IF NOT EXISTS public.profiles (
5
  id UUID REFERENCES auth.users ON DELETE CASCADE PRIMARY KEY,
6
  email TEXT,
@@ -8,16 +8,41 @@ CREATE TABLE IF NOT EXISTS public.profiles (
8
  avatar_url TEXT,
9
  razorpay_subscription_id TEXT,
10
  plan TEXT DEFAULT 'free' CHECK (plan IN ('free', 'pro', 'team')),
 
11
  analyses_this_month INT DEFAULT 0,
12
  monthly_reset_at TIMESTAMPTZ DEFAULT date_trunc('month', NOW()),
13
  created_at TIMESTAMPTZ DEFAULT NOW(),
14
  updated_at TIMESTAMPTZ DEFAULT NOW()
15
  );
16
 
17
- -- Analyses
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
  CREATE TABLE IF NOT EXISTS public.analyses (
19
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
20
  user_id UUID REFERENCES public.profiles ON DELETE CASCADE NOT NULL,
 
21
  source_url TEXT,
22
  source_type TEXT DEFAULT 'tos' CHECK (source_type IN ('tos', 'contract', 'rental', 'other')),
23
  total_clauses INT NOT NULL,
@@ -28,21 +53,86 @@ CREATE TABLE IF NOT EXISTS public.analyses (
28
  created_at TIMESTAMPTZ DEFAULT NOW()
29
  );
30
 
31
- -- Indexes
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
  CREATE INDEX IF NOT EXISTS idx_analyses_user_id ON public.analyses(user_id);
 
33
  CREATE INDEX IF NOT EXISTS idx_analyses_created_at ON public.analyses(created_at DESC);
 
 
 
 
 
34
 
35
- -- Row Level Security
36
  ALTER TABLE public.profiles ENABLE ROW LEVEL SECURITY;
37
  ALTER TABLE public.analyses ENABLE ROW LEVEL SECURITY;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
 
39
- CREATE POLICY "Users can view own profile" ON public.profiles FOR SELECT USING (auth.uid() = id);
40
- CREATE POLICY "Users can update own profile" ON public.profiles FOR UPDATE USING (auth.uid() = id);
41
- CREATE POLICY "Users can view own analyses" ON public.analyses FOR SELECT USING (auth.uid() = user_id);
42
- CREATE POLICY "Users can insert own analyses" ON public.analyses FOR INSERT WITH CHECK (auth.uid() = user_id);
43
- CREATE POLICY "Users can delete own analyses" ON public.analyses FOR DELETE USING (auth.uid() = user_id);
44
 
45
- -- Auto-create profile on signup
46
  CREATE OR REPLACE FUNCTION public.handle_new_user()
47
  RETURNS TRIGGER AS $$
48
  BEGIN
@@ -56,16 +146,17 @@ BEGIN
56
  END;
57
  $$ LANGUAGE plpgsql SECURITY DEFINER;
58
 
59
- CREATE OR REPLACE TRIGGER on_auth_user_created
 
60
  AFTER INSERT ON auth.users
61
  FOR EACH ROW EXECUTE FUNCTION public.handle_new_user();
62
 
63
- -- Monthly usage reset
64
  CREATE OR REPLACE FUNCTION public.reset_monthly_usage()
65
  RETURNS void AS $$
66
  BEGIN
67
- UPDATE public.profiles
68
- SET analyses_this_month = 0, monthly_reset_at = date_trunc('month', NOW())
69
  WHERE monthly_reset_at < date_trunc('month', NOW());
 
70
  END;
71
  $$ LANGUAGE plpgsql SECURITY DEFINER;
 
1
+ -- ClauseGuard β€” Full Database Schema (with Teams, API Keys, Custom Rules)
2
 
3
+ -- ─── Profiles ───
4
  CREATE TABLE IF NOT EXISTS public.profiles (
5
  id UUID REFERENCES auth.users ON DELETE CASCADE PRIMARY KEY,
6
  email TEXT,
 
8
  avatar_url TEXT,
9
  razorpay_subscription_id TEXT,
10
  plan TEXT DEFAULT 'free' CHECK (plan IN ('free', 'pro', 'team')),
11
+ team_id UUID REFERENCES public.teams(id) ON DELETE SET NULL,
12
  analyses_this_month INT DEFAULT 0,
13
  monthly_reset_at TIMESTAMPTZ DEFAULT date_trunc('month', NOW()),
14
  created_at TIMESTAMPTZ DEFAULT NOW(),
15
  updated_at TIMESTAMPTZ DEFAULT NOW()
16
  );
17
 
18
+ -- ─── Teams ───
19
+ CREATE TABLE IF NOT EXISTS public.teams (
20
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
21
+ name TEXT NOT NULL,
22
+ owner_id UUID REFERENCES auth.users NOT NULL,
23
+ plan TEXT DEFAULT 'team',
24
+ max_seats INT DEFAULT 5,
25
+ razorpay_subscription_id TEXT,
26
+ created_at TIMESTAMPTZ DEFAULT NOW()
27
+ );
28
+
29
+ CREATE TABLE IF NOT EXISTS public.team_invites (
30
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
31
+ team_id UUID REFERENCES public.teams ON DELETE CASCADE NOT NULL,
32
+ email TEXT NOT NULL,
33
+ role TEXT DEFAULT 'member' CHECK (role IN ('admin', 'member')),
34
+ status TEXT DEFAULT 'pending' CHECK (status IN ('pending', 'accepted', 'expired')),
35
+ invited_by UUID REFERENCES auth.users NOT NULL,
36
+ created_at TIMESTAMPTZ DEFAULT NOW(),
37
+ expires_at TIMESTAMPTZ DEFAULT NOW() + INTERVAL '7 days',
38
+ UNIQUE(team_id, email)
39
+ );
40
+
41
+ -- ─── Analyses ───
42
  CREATE TABLE IF NOT EXISTS public.analyses (
43
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
44
  user_id UUID REFERENCES public.profiles ON DELETE CASCADE NOT NULL,
45
+ team_id UUID REFERENCES public.teams ON DELETE SET NULL,
46
  source_url TEXT,
47
  source_type TEXT DEFAULT 'tos' CHECK (source_type IN ('tos', 'contract', 'rental', 'other')),
48
  total_clauses INT NOT NULL,
 
53
  created_at TIMESTAMPTZ DEFAULT NOW()
54
  );
55
 
56
+ -- ─── API Keys ───
57
+ CREATE TABLE IF NOT EXISTS public.api_keys (
58
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
59
+ user_id UUID REFERENCES public.profiles ON DELETE CASCADE NOT NULL,
60
+ team_id UUID REFERENCES public.teams ON DELETE CASCADE,
61
+ name TEXT NOT NULL,
62
+ key_hash TEXT NOT NULL UNIQUE,
63
+ key_prefix TEXT NOT NULL, -- first 8 chars for display: "cg_live_a1b2..."
64
+ calls_this_month INT DEFAULT 0,
65
+ calls_limit INT DEFAULT 1000, -- Pro: 1000, Team: 10000
66
+ last_used_at TIMESTAMPTZ,
67
+ is_active BOOLEAN DEFAULT true,
68
+ created_at TIMESTAMPTZ DEFAULT NOW()
69
+ );
70
+
71
+ -- ─── Custom Clause Rules ───
72
+ CREATE TABLE IF NOT EXISTS public.custom_rules (
73
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
74
+ user_id UUID REFERENCES public.profiles ON DELETE CASCADE,
75
+ team_id UUID REFERENCES public.teams ON DELETE CASCADE,
76
+ name TEXT NOT NULL,
77
+ description TEXT,
78
+ pattern TEXT NOT NULL, -- regex pattern to match
79
+ severity TEXT DEFAULT 'MEDIUM' CHECK (severity IN ('HIGH', 'MEDIUM', 'LOW')),
80
+ category TEXT NOT NULL, -- custom category name
81
+ is_active BOOLEAN DEFAULT true,
82
+ created_at TIMESTAMPTZ DEFAULT NOW(),
83
+ updated_at TIMESTAMPTZ DEFAULT NOW()
84
+ );
85
+
86
+ -- ─── Indexes ───
87
  CREATE INDEX IF NOT EXISTS idx_analyses_user_id ON public.analyses(user_id);
88
+ CREATE INDEX IF NOT EXISTS idx_analyses_team_id ON public.analyses(team_id);
89
  CREATE INDEX IF NOT EXISTS idx_analyses_created_at ON public.analyses(created_at DESC);
90
+ CREATE INDEX IF NOT EXISTS idx_api_keys_key_hash ON public.api_keys(key_hash);
91
+ CREATE INDEX IF NOT EXISTS idx_api_keys_user_id ON public.api_keys(user_id);
92
+ CREATE INDEX IF NOT EXISTS idx_team_invites_email ON public.team_invites(email);
93
+ CREATE INDEX IF NOT EXISTS idx_custom_rules_user_id ON public.custom_rules(user_id);
94
+ CREATE INDEX IF NOT EXISTS idx_custom_rules_team_id ON public.custom_rules(team_id);
95
 
96
+ -- ─── Row Level Security ───
97
  ALTER TABLE public.profiles ENABLE ROW LEVEL SECURITY;
98
  ALTER TABLE public.analyses ENABLE ROW LEVEL SECURITY;
99
+ ALTER TABLE public.teams ENABLE ROW LEVEL SECURITY;
100
+ ALTER TABLE public.team_invites ENABLE ROW LEVEL SECURITY;
101
+ ALTER TABLE public.api_keys ENABLE ROW LEVEL SECURITY;
102
+ ALTER TABLE public.custom_rules ENABLE ROW LEVEL SECURITY;
103
+
104
+ -- Profiles
105
+ CREATE POLICY "Users see own profile" ON public.profiles FOR SELECT USING (auth.uid() = id);
106
+ CREATE POLICY "Users update own profile" ON public.profiles FOR UPDATE USING (auth.uid() = id);
107
+
108
+ -- Analyses: own + team
109
+ CREATE POLICY "Users see own analyses" ON public.analyses FOR SELECT
110
+ USING (auth.uid() = user_id OR team_id IN (SELECT team_id FROM public.profiles WHERE id = auth.uid()));
111
+ CREATE POLICY "Users insert analyses" ON public.analyses FOR INSERT WITH CHECK (auth.uid() = user_id);
112
+ CREATE POLICY "Users delete own analyses" ON public.analyses FOR DELETE USING (auth.uid() = user_id);
113
+
114
+ -- Teams: members can view, owner can update
115
+ CREATE POLICY "Team members can view" ON public.teams FOR SELECT
116
+ USING (id IN (SELECT team_id FROM public.profiles WHERE id = auth.uid()) OR owner_id = auth.uid());
117
+ CREATE POLICY "Owner can update team" ON public.teams FOR UPDATE USING (owner_id = auth.uid());
118
+
119
+ -- Team invites: team members can view, admins can insert
120
+ CREATE POLICY "Members see team invites" ON public.team_invites FOR SELECT
121
+ USING (team_id IN (SELECT team_id FROM public.profiles WHERE id = auth.uid()));
122
+ CREATE POLICY "Admins can invite" ON public.team_invites FOR INSERT
123
+ WITH CHECK (invited_by = auth.uid());
124
+
125
+ -- API Keys: own + team
126
+ CREATE POLICY "Users see own API keys" ON public.api_keys FOR SELECT
127
+ USING (user_id = auth.uid() OR team_id IN (SELECT team_id FROM public.profiles WHERE id = auth.uid()));
128
+ CREATE POLICY "Users manage own API keys" ON public.api_keys FOR ALL USING (user_id = auth.uid());
129
 
130
+ -- Custom Rules: own + team
131
+ CREATE POLICY "Users see own rules" ON public.custom_rules FOR SELECT
132
+ USING (user_id = auth.uid() OR team_id IN (SELECT team_id FROM public.profiles WHERE id = auth.uid()));
133
+ CREATE POLICY "Users manage own rules" ON public.custom_rules FOR ALL USING (user_id = auth.uid());
 
134
 
135
+ -- ─── Auto-create profile on signup ───
136
  CREATE OR REPLACE FUNCTION public.handle_new_user()
137
  RETURNS TRIGGER AS $$
138
  BEGIN
 
146
  END;
147
  $$ LANGUAGE plpgsql SECURITY DEFINER;
148
 
149
+ DROP TRIGGER IF EXISTS on_auth_user_created ON auth.users;
150
+ CREATE TRIGGER on_auth_user_created
151
  AFTER INSERT ON auth.users
152
  FOR EACH ROW EXECUTE FUNCTION public.handle_new_user();
153
 
154
+ -- ─── Monthly reset ───
155
  CREATE OR REPLACE FUNCTION public.reset_monthly_usage()
156
  RETURNS void AS $$
157
  BEGIN
158
+ UPDATE public.profiles SET analyses_this_month = 0, monthly_reset_at = date_trunc('month', NOW())
 
159
  WHERE monthly_reset_at < date_trunc('month', NOW());
160
+ UPDATE public.api_keys SET calls_this_month = 0;
161
  END;
162
  $$ LANGUAGE plpgsql SECURITY DEFINER;
web/next.config.ts CHANGED
@@ -2,7 +2,7 @@ import type { NextConfig } from "next";
2
 
3
  const nextConfig: NextConfig = {
4
  output: "standalone",
5
- serverExternalPackages: ["@react-pdf/renderer"],
6
  images: {
7
  remotePatterns: [
8
  { protocol: "https", hostname: "avatars.githubusercontent.com" },
 
2
 
3
  const nextConfig: NextConfig = {
4
  output: "standalone",
5
+ serverExternalPackages: ["@react-pdf/renderer", "pdf-parse", "mammoth"],
6
  images: {
7
  remotePatterns: [
8
  { protocol: "https", hostname: "avatars.githubusercontent.com" },
web/package.json CHANGED
@@ -17,6 +17,8 @@
17
  "razorpay": "2.9.6",
18
  "resend": "6.12.2",
19
  "@react-pdf/renderer": "4.5.1",
 
 
20
  "jose": "6.2.2",
21
  "lucide-react": "0.474.0",
22
  "clsx": "2.1.1",
 
17
  "razorpay": "2.9.6",
18
  "resend": "6.12.2",
19
  "@react-pdf/renderer": "4.5.1",
20
+ "pdf-parse": "2.4.5",
21
+ "mammoth": "1.12.0",
22
  "jose": "6.2.2",
23
  "lucide-react": "0.474.0",
24
  "clsx": "2.1.1",