Spaces:
Sleeping
Sleeping
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 +70 -0
- web/app/api/custom-rules/route.ts +94 -0
- web/app/api/parse-upload/route.ts +45 -0
- web/app/api/teams/route.ts +99 -0
- web/app/dashboard-pages/team/page.tsx +142 -0
- web/lib/supabase/schema.sql +106 -15
- web/next.config.ts +1 -1
- web/package.json +2 -0
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 β
|
| 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 |
-
--
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
--
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 40 |
-
CREATE POLICY "Users
|
| 41 |
-
|
| 42 |
-
CREATE POLICY "Users
|
| 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 |
-
|
|
|
|
| 60 |
AFTER INSERT ON auth.users
|
| 61 |
FOR EACH ROW EXECUTE FUNCTION public.handle_new_user();
|
| 62 |
|
| 63 |
-
-- Monthly
|
| 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",
|