diff --git a/app/migrations/0011_sour_maria_hill.sql b/app/migrations/0011_sour_maria_hill.sql new file mode 100644 index 0000000..08c6cea --- /dev/null +++ b/app/migrations/0011_sour_maria_hill.sql @@ -0,0 +1,11 @@ +-- Step 1: Set default if not already set +ALTER TABLE "user" ALTER COLUMN "role" SET DEFAULT 'USER'; + +-- Step 2: Fix existing data +UPDATE "user" SET role = 'USER' WHERE role IS NULL; + +-- Step 3: Set NOT NULL constraint +ALTER TABLE "user" ALTER COLUMN "role" SET NOT NULL; + +-- Step 4: New Column added and Set NOT NULL constraint with default value +ALTER TABLE "subscriptions" ADD COLUMN "price" integer DEFAULT 0 NOT NULL; \ No newline at end of file diff --git a/app/migrations/meta/0011_snapshot.json b/app/migrations/meta/0011_snapshot.json new file mode 100644 index 0000000..8d2d584 --- /dev/null +++ b/app/migrations/meta/0011_snapshot.json @@ -0,0 +1,424 @@ +{ + "id": "35e9d28c-c5e6-4839-af7a-89cd21d439d9", + "prevId": "3735879d-ed33-4d4e-bc88-a1a2865bebfd", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.bot": { + "name": "bot", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "botName": { + "name": "botName", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "botEmail": { + "name": "botEmail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "botHd": { + "name": "botHd", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "botPicture": { + "name": "botPicture", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "accessToken": { + "name": "accessToken", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refreshToken": { + "name": "refreshToken", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "bot_user_id_user_id_fk": { + "name": "bot_user_id_user_id_fk", + "tableFrom": "bot", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.registrations": { + "name": "registrations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "userName": { + "name": "userName", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "userEmail": { + "name": "userEmail", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.subscriptions": { + "name": "subscriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "recordingCount": { + "name": "recordingCount", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "fileSizeLimitMB": { + "name": "fileSizeLimitMB", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "durationDays": { + "name": "durationDays", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "price": { + "name": "price", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "subscriptions_name_unique": { + "name": "subscriptions_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + } + }, + "public.transcriptions": { + "name": "transcriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "translation": { + "name": "translation", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "segments": { + "name": "segments", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "documentUrl": { + "name": "documentUrl", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "documentName": { + "name": "documentName", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "isDefault": { + "name": "isDefault", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "audioDuration": { + "name": "audioDuration", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "user_name": { + "name": "user_name", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "transcriptions_user_id_user_id_fk": { + "name": "transcriptions_user_id_user_id_fk", + "tableFrom": "transcriptions", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "contactNumber": { + "name": "contactNumber", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subscriptionId": { + "name": "subscriptionId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'USER'" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_subscriptionId_subscriptions_id_fk": { + "name": "user_subscriptionId_subscriptions_id_fk", + "tableFrom": "user", + "tableTo": "subscriptions", + "columnsFrom": [ + "subscriptionId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_username_unique": { + "name": "user_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + } + } + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/app/migrations/meta/_journal.json b/app/migrations/meta/_journal.json index 2d8786f..c87d851 100644 --- a/app/migrations/meta/_journal.json +++ b/app/migrations/meta/_journal.json @@ -78,6 +78,13 @@ "when": 1750831412800, "tag": "0010_brainy_pixie", "breakpoints": true + }, + { + "idx": 11, + "version": "7", + "when": 1750919306282, + "tag": "0011_sour_maria_hill", + "breakpoints": true } ] } \ No newline at end of file diff --git a/app/src/app/admin/components/EditSubscription.tsx b/app/src/app/admin/components/EditSubscription.tsx new file mode 100644 index 0000000..d38620a --- /dev/null +++ b/app/src/app/admin/components/EditSubscription.tsx @@ -0,0 +1,220 @@ +"use client"; +import { useEffect, useState } from "react"; +import { API } from "@/lib/axios"; +import { toast } from "sonner"; +import { useRouter } from "next/navigation"; +import { Check, Loader2 } from "lucide-react"; + +const EditSubscription = ({ + userId, + setIsModalOpen, +}: { + userId: string | null; + setIsModalOpen: any; +}) => { + const router = useRouter(); + const [profileData, setProfileData] = useState({ + username: "", + name: "", + contactNumber: "", + role: "", + subscription: { + id: "", + name: "", + recordingCount: 0, + fileSizeLimitMB: 0, + durationDays: 0, + }, + recordingsUsed: 0, + recordingsRemaining: 0, + }); + const [hasChanged, setHasChanged] = useState(false); + const [subscriptions, setSubscriptions] = useState< + { + id: string; + name: string; + recordingCount: number; + fileSizeLimitMB: number; + durationDays: number; + }[] + >([]); + + const [selectedSubscriptionId, setSelectedSubscriptionId] = useState(""); + const [loading, setLoading] = useState(true); + const [updating, setUpdating] = useState(false); + + useEffect(() => { + const fetchData = async () => { + try { + if (!userId) return; + + const [userRes, subsRes] = await Promise.all([ + API.get(`/admin/users/${userId}`), + API.get("/admin/subscriptions"), + ]); + + const user = userRes.data; + const subs = subsRes.data.subscriptions; + + setSubscriptions(subs); + + const selectedSub = subs.find( + (s: any) => s.name === user.subscription?.name + ); + + const subId = selectedSub?.id || ""; + + setProfileData({ + username: user.username || "", + name: user.name || "", + contactNumber: user.contactNumber || "", + role: user.role || "", + subscription: { + id: subId, + name: selectedSub?.name || "", + recordingCount: selectedSub?.recordingCount || 0, + fileSizeLimitMB: selectedSub?.fileSizeLimitMB || 0, + durationDays: selectedSub?.durationDays || 0, + }, + recordingsUsed: user.recordingsUsed || 0, + recordingsRemaining: user.recordingsRemaining || 0, + }); + + setSelectedSubscriptionId(subId); + } catch (error) { + console.error("Error fetching data:", error); + } finally { + setLoading(false); + } + }; + + fetchData(); + }, [userId]); + + const { + name, + username, + contactNumber, + role, + subscription, + recordingsUsed, + recordingsRemaining, + } = profileData; + + const handleSubscriptionChange = (subscriptionId: string) => { + const selected = subscriptions.find((s) => s.id === subscriptionId); + if (!selected) return; + + setSelectedSubscriptionId(subscriptionId); + }; + + const handleUpdate = async () => { + try { + setUpdating(true); + await API.patch(`/admin/users/${userId}`, { + subscriptionId: selectedSubscriptionId, + }); + toast.success("Subscription updated successfully"); + router.push("/admin/users"); + setIsModalOpen(null); + // alert("Subscription updated successfully"); + } catch (err) { + console.error("Update failed:", err); + // alert("Failed to update subscription."); + } finally { + setUpdating(false); + } + }; + useEffect(() => { + setHasChanged(selectedSubscriptionId !== subscription.id); + }, [selectedSubscriptionId]); + + return ( +
+ {/* Profile Initial */} +
+ {loading ? ( +
+ ) : ( + name?.charAt(0)?.toUpperCase() || "?" + )} +
+ + {/* Basic Info */} +
+

+ {loading ? "..." : name || "N/A"} +

+

{username || "N/A"}

+

{contactNumber || "N/A"}

+

{role || "N/A"}

+
+ + {/* Separator */} +
+ + {/* Subscription Plan Selector */} +
+
+ + Subscription Plan: + + +
+ + + {hasChanged && ( + + )} +
+
+
+ + {/* Plan Details */} + {!loading && ( +
+

+ Plan Details +

+
+
+ Limit + {subscription.recordingCount} +
+
+ Used Recordings + {recordingsUsed} +
+
+ Remaining Recordings + {recordingsRemaining} +
+
+
+ )} +
+ ); +}; + +export default EditSubscription; diff --git a/app/src/app/admin/components/SubscriptionEdit.tsx b/app/src/app/admin/components/SubscriptionEdit.tsx new file mode 100644 index 0000000..5007a84 --- /dev/null +++ b/app/src/app/admin/components/SubscriptionEdit.tsx @@ -0,0 +1,98 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { SubscriptionData } from "../subscriptions/page"; +import { API } from "@/lib/axios"; +import { toast } from "sonner"; + +type Props = { + recording: SubscriptionData; + onClose: () => void; +}; + +const SubscriptionEdit = ({ recording, onClose }: Props) => { + const [editData, setEditData] = useState(recording); + const [editingField, setEditingField] = useState(null); + const [updating, setUpdating] = useState(false); + console.log("recording", recording); + useEffect(() => { + setEditData(recording); + }, [recording]); + + const handleChange = ( + key: keyof SubscriptionData, + value: string | number + ) => { + setEditData((prev) => ({ ...prev, [key]: value })); + }; + + const handleSave = async () => { + setUpdating(true); + try { + const { id, ...data } = editData; + const response = await API.patch( + `/admin/subscriptions/free?id=${id}`, + data + ); + + const result = response.data; + console.log("Updated subscription:", result); + // Close modal + } catch (err: any) { + console.error("Save error:", err); + toast.error(err?.response.data.error || "Failed to update subscription"); + } finally { + onClose(); + setUpdating(false); + } + }; + + const renderField = (label: string, key: keyof SubscriptionData) => ( +
+ +
+ + handleChange( + key, + key === "name" ? e.target.value : parseFloat(e.target.value) + ) + } + /> +
+
+ ); + + return ( +
+

Edit Subscription Details

+ {renderField("File Name", "name")} + {renderField("Recording Count", "recordingCount")} + {renderField("File Size Limit (MB)", "fileSizeLimitMB")} + {renderField("Duration (Days)", "durationDays")} + {renderField("Price", "price")} + +
+ + +
+
+ ); +}; + +export default SubscriptionEdit; diff --git a/app/src/app/admin/layout.tsx b/app/src/app/admin/layout.tsx new file mode 100644 index 0000000..825366f --- /dev/null +++ b/app/src/app/admin/layout.tsx @@ -0,0 +1,30 @@ +// app/admin/layout.tsx +import Link from "next/link"; +import { validateRequest } from "@/auth"; +import { redirect } from "next/navigation"; + +export default async function AdminLayout({ + children, +}: { + children: React.ReactNode; +}) { + const { user } = await validateRequest(); + if (!user || user.role !== "ADMIN") redirect("/"); + + return ( +
+ +
{children}
+
+ ); +} diff --git a/app/src/app/admin/page.tsx b/app/src/app/admin/page.tsx new file mode 100644 index 0000000..e69de29 diff --git a/app/src/app/admin/recordings/[id]/page.tsx b/app/src/app/admin/recordings/[id]/page.tsx new file mode 100644 index 0000000..e925876 --- /dev/null +++ b/app/src/app/admin/recordings/[id]/page.tsx @@ -0,0 +1,92 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useSearchParams } from "next/navigation"; +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent } from "@/components/ui/card"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Trash2 } from "lucide-react"; +import { API } from "@/lib/axios"; + +interface PageProps { + params: { + id: string; + }; +} + +interface Recording { + id: string; + documentName: string; + audioDuration: number; + createdAt: string; + translation: string; + summary: string; + isDefault: boolean; +} + +export default function RecordingsPage({ params }: PageProps) { + const [recordings, setRecordings] = useState([]); + const searchParams = useSearchParams(); + const page = searchParams.get("page") || "1"; + const limit = searchParams.get("limit") || "20"; + + useEffect(() => { + const fetchRecordings = async () => { + try { + const response = await API.get( + `/admin/users/transcriptions/${params.id}?page=${page}&limit=${limit}` + ); + setRecordings(response.data.data); // data array from response + } catch (error) { + console.error("Failed to fetch recordings:", error); + } + }; + + fetchRecordings(); + }, [params.id, page, limit]); + + return ( + + +

All Recordings

+ + + + File Name + Duration (s) + Upload Date + Action + + + + {recordings.map((audioFile) => ( + + + {audioFile.documentName} + + + {audioFile.audioDuration} + + + {new Date(audioFile.createdAt).toLocaleDateString()} + + +
+ {}} size={20} /> +
+
+
+ ))} +
+
+
+
+ ); +} \ No newline at end of file diff --git a/app/src/app/admin/subscriptions/page.tsx b/app/src/app/admin/subscriptions/page.tsx new file mode 100644 index 0000000..624949c --- /dev/null +++ b/app/src/app/admin/subscriptions/page.tsx @@ -0,0 +1,108 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { API } from "@/lib/axios"; +import { Card, CardContent } from "@/components/ui/card"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Edit2 } from "lucide-react"; +import { Modal } from "@/components/ui/modal"; +import SubscriptionEdit from "../components/SubscriptionEdit"; + +export type SubscriptionData = { + id:string, + name: string; + recordingCount: number; + price: number; + fileSizeLimitMB: number; + durationDays: number; +}; + +export default function SubscriptionsPage() { + const [subs, setSubs] = useState([]); + const [loading, setLoading] = useState(false); + const [selectedSubscriptionId, setSelectedSubscriptionId] = useState(""); + const [isModalOpen, setIsModalOpen] = useState(null); + + const fetchSubscriptions = async () => { + try { + setLoading(true); + const { data } = await API.get("/admin/subscriptions"); + setSubs(data.subscriptions); + } catch (err) { + console.error("Failed to fetch subscriptions:", err); + } finally { + setLoading(false); + } + }; +useEffect(() => { + if(selectedSubscriptionId) setIsModalOpen(subs.find((s: any) => s.id === selectedSubscriptionId)) +}, [selectedSubscriptionId]) + useEffect(() => { + fetchSubscriptions(); + }, [isModalOpen]); + + return ( + <> + + +

Subscriptions

+ {loading ? ( +

Loading...

+ ) : ( + + + + Name + Recording Count + File Size Limit (MB) + Duration (Days) + Price + Actions + + + + {subs.map((s) => ( + + {s.name} + {s.recordingCount} + {s.fileSizeLimitMB} + {s.durationDays} + {s.price} + +
setSelectedSubscriptionId(s.id)} + className="w-8 h-8 p-2 rounded-full flex items-center justify-center shadow hover:bg-green-500 cursor-pointer border-green-800 hover:text-white" + > + +
+
+
+ ))} +
+
+ )} +
+
+ + setIsModalOpen(null)} + title="Update Subscription" + > + {isModalOpen && ( + setIsModalOpen(null)} + /> + )} + + + ); +} diff --git a/app/src/app/admin/users/[id]/recordings/RecordingsPage.tsx b/app/src/app/admin/users/[id]/recordings/RecordingsPage.tsx new file mode 100644 index 0000000..96fde19 --- /dev/null +++ b/app/src/app/admin/users/[id]/recordings/RecordingsPage.tsx @@ -0,0 +1,95 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useSearchParams } from "next/navigation"; +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent } from "@/components/ui/card"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Trash2 } from "lucide-react"; +import { API } from "@/lib/axios"; + +interface Recording { + id: string; + documentName: string; + audioDuration: number; + createdAt: string; + translation: string; + summary: string; + isDefault: boolean; +} + +interface Props { + userId: string; +} + +export default function RecordingsPage({ userId }: Props) { + const [recordings, setRecordings] = useState([]); + const searchParams = useSearchParams(); + const page = searchParams.get("page") || "1"; + const limit = searchParams.get("limit") || "20"; + + useEffect(() => { + const fetchRecordings = async () => { + try { + const response = await API.get( + `/admin/users/transcriptions/${userId}?page=${page}&limit=${limit}` + ); + setRecordings(response.data.data); + } catch (error) { + console.error("Failed to fetch recordings:", error); + } + }; + + fetchRecordings(); + }, [userId, page, limit]); + + return ( + + +

All Recordings

+ + + + File Name + Duration (s) + Upload Date + Action + + + + {recordings.map((audioFile) => ( + + + {audioFile.documentName} + + + {audioFile.audioDuration} + + + {new Date(audioFile.createdAt).toLocaleDateString()} + + +
{ + // Add delete logic here + }} + > + +
+
+
+ ))} +
+
+
+
+ ); +} diff --git a/app/src/app/admin/users/[id]/recordings/page.tsx b/app/src/app/admin/users/[id]/recordings/page.tsx new file mode 100644 index 0000000..71ee52c --- /dev/null +++ b/app/src/app/admin/users/[id]/recordings/page.tsx @@ -0,0 +1,11 @@ +import RecordingsPage from "./RecordingsPage"; + +interface PageProps { + params: { + id: string; + }; +} + +export default function RecordingsWrapper({ params }: PageProps) { + return ; +} diff --git a/app/src/app/admin/users/page.tsx b/app/src/app/admin/users/page.tsx new file mode 100644 index 0000000..1e7dc5f --- /dev/null +++ b/app/src/app/admin/users/page.tsx @@ -0,0 +1,104 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { API } from "@/lib/axios"; +import { Card, CardContent } from "@/components/ui/card"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Edit2 } from "lucide-react"; +import { Modal } from "@/components/ui/modal"; +import { useRouter } from "next/navigation"; +import EditSubscription from "../components/EditSubscription"; + +export default function UsersPage() { + const [users, setUsers] = useState([]); + const [loading, setLoading] = useState(false); + const [isModalOpen, setIsModalOpen] = useState(null); + const router = useRouter(); + + const fetchUsers = async () => { + try { + setLoading(true); + const { data } = await API.get("/admin/users"); // Capitalized route path + setUsers(data.users); + } catch (err) { + console.error("Failed to fetch users:", err); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + if (!isModalOpen) { + fetchUsers(); + } + fetchUsers(); + }, [isModalOpen]); + + return ( + <> + + +

Users

+ {loading ? ( +

Loading...

+ ) : ( + + + + Username + Name + Role + Subscription + Actions + + + + {users.map((u) => ( + router.push(`/admin/users/${u.id}/recordings`)} + > + {u.username} + {u.name} + {u.role} + {u.subscription.name} + +
{ + e.stopPropagation(); + setIsModalOpen(u.id); + }} + className="w-8 h-8 p-2 rounded-full flex items-center justify-center shadow-xl hover:bg-green-500 cursor-pointer border-green-800 hover:text-white" + > + +
+ +
+
+ ))} +
+
+ )} +
+
+ setIsModalOpen(null)} + title="Update Subscription" + > + + + + ); +} diff --git a/app/src/app/api/admin/subscriptions/[id]/route.ts b/app/src/app/api/admin/subscriptions/[id]/route.ts new file mode 100644 index 0000000..ef7472a --- /dev/null +++ b/app/src/app/api/admin/subscriptions/[id]/route.ts @@ -0,0 +1,78 @@ +import { db } from "@/db"; +import { subscriptionTable } from "@/db/schema"; +import { withAdmin } from "@/lib/withAdmin"; +import { eq } from "drizzle-orm"; +import { NextRequest, NextResponse } from "next/server"; + +// GET /api/admin/subscriptions/:id +export const GET = withAdmin(async function ( + _req: NextRequest, + { params }: { params: { id: string } } +) { + const { id } = params; + + const subscription = await db + .select() + .from(subscriptionTable) + .where(eq(subscriptionTable.id, id)) + .then((res) => res[0]); + + if (!subscription) { + return NextResponse.json( + { error: "Subscription not found" }, + { status: 404 } + ); + } + + return NextResponse.json(subscription); +}); + +// PATCH /api/admin/subscriptions/:id +export const PATCH = withAdmin(async function ( + req: NextRequest, + { params }: { params: { id: string } } +) { + const data = await req.json(); + const { id } = params; + + if (!id) { + return NextResponse.json( + { error: "Subscription ID is required" }, + { status: 400 } + ); + } + + try { + const [updated] = await db + .update(subscriptionTable) + .set(data) + .where(eq(subscriptionTable.id, id)) + .returning(); + + if (!updated) { + return NextResponse.json( + { error: "Subscription not found" }, + { status: 404 } + ); + } + + return NextResponse.json(updated); + } catch (error: any) { + // Handle unique constraint error (PostgreSQL code 23505) + if (error.code === "23505") { + if (error.constraint === "subscriptions_name_unique") { + return NextResponse.json( + { error: "Subscription name already exists" }, + { status: 409 } + ); + } + } + + // Generic DB error fallback + console.error("DB Error:", error); + return NextResponse.json( + { error: "An unexpected error occurred" }, + { status: 500 } + ); + } +}); diff --git a/app/src/app/api/admin/subscriptions/route.ts b/app/src/app/api/admin/subscriptions/route.ts new file mode 100644 index 0000000..2148028 --- /dev/null +++ b/app/src/app/api/admin/subscriptions/route.ts @@ -0,0 +1,66 @@ +// src/app/api/admin/subscriptions/route.ts + +import { db } from "@/db"; +import { subscriptionTable } from "@/db/schema"; +import { withAdmin } from "@/lib/withAdmin"; +import { ilike } from "drizzle-orm"; +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; + +// Define schema for request validation +const subscriptionSchema = z.object({ + name: z.string().min(1), + recordingCount: z.number().int().min(0), + fileSizeLimitMB: z.number().int().min(1), + durationDays: z.number().int().min(1), + price: z.number().int().min(0).default(0), +}); + +// GET: Fetch all subscriptions +export const GET = withAdmin(async function () { + const subscriptions = await db.select().from(subscriptionTable); + return NextResponse.json({ subscriptions }); +}); + +// POST: Create a new subscription + +export const POST = withAdmin(async function (req: NextRequest) { + try { + const json = await req.json(); + const data = subscriptionSchema.parse(json); + + // 🔍 Check if a subscription with the same name (case-insensitive) exists + const existing = await db + .select() + .from(subscriptionTable) + .where(ilike(subscriptionTable.name, data.name)); + + if (existing.length > 0) { + return NextResponse.json( + { error: `Subscription "${data.name}" already exists` }, + { status: 409 } + ); + } + + const [newSub] = await db + .insert(subscriptionTable) + .values(data) + .returning(); + + return NextResponse.json(newSub, { status: 201 }); + } catch (err) { + console.error("POST /admin/subscriptions error:", err); + + if (err instanceof z.ZodError) { + return NextResponse.json( + { error: "Validation failed", issues: err.errors }, + { status: 422 } + ); + } + + return NextResponse.json( + { error: "Failed to create subscription" }, + { status: 500 } + ); + } +}); diff --git a/app/src/app/api/admin/transcriptions/[id]/route.ts b/app/src/app/api/admin/transcriptions/[id]/route.ts new file mode 100644 index 0000000..3229c24 --- /dev/null +++ b/app/src/app/api/admin/transcriptions/[id]/route.ts @@ -0,0 +1,47 @@ +import { db } from "@/db"; +import { transcriptions } from "@/db/schema"; +import { withAdmin } from "@/lib/withAdmin"; +import { eq } from "drizzle-orm"; +import { NextRequest, NextResponse } from "next/server"; + +export const GET = withAdmin(async function ( + _req: NextRequest, + { params }: { params: { id: string } } +) { + const { id } = params; + + const [transcription] = await db + .select() + .from(transcriptions) + .where(eq(transcriptions.id, id)); + + if (!transcription) { + return NextResponse.json( + { error: "Transcription not found" }, + { status: 404 } + ); + } + + return NextResponse.json(transcription); +}); + +export const DELETE = withAdmin(async function ( + _req: NextRequest, + { params }: { params: { id: string } } +) { + const { id } = params; + + const [deleted] = await db + .delete(transcriptions) + .where(eq(transcriptions.id, id)) + .returning(); + + if (!deleted) { + return NextResponse.json( + { error: "Transcription not found" }, + { status: 404 } + ); + } + + return NextResponse.json({ success: true, id }); +}); diff --git a/app/src/app/api/admin/transcriptions/sample/route.ts b/app/src/app/api/admin/transcriptions/sample/route.ts new file mode 100644 index 0000000..c2c9cd3 --- /dev/null +++ b/app/src/app/api/admin/transcriptions/sample/route.ts @@ -0,0 +1,41 @@ +import { db } from "@/db"; +import { transcriptions } from "@/db/schema"; +import { withAdmin } from "@/lib/withAdmin"; +import { eq, sql } from "drizzle-orm"; +import { NextRequest, NextResponse } from "next/server"; + +export const GET = withAdmin(async function (req: NextRequest) { + const { searchParams } = new URL(req.url); + const page = parseInt(searchParams.get("page") || "1"); + const limit = parseInt(searchParams.get("limit") || "10"); + const offset = (page - 1) * limit; + + // Fetch sample transcriptions + const sampleTranscriptions = await db + .select({ + id: transcriptions.id, + documentName: transcriptions.documentName, + translation: transcriptions.translation, + summary: transcriptions.summary, + createdAt: transcriptions.createdAt, + audioDuration: transcriptions.audioDuration, + isDefault: transcriptions.isDefault, + }) + .from(transcriptions) + .where(eq(transcriptions.isDefault, true)) + .limit(limit) + .offset(offset); + + const [{ count }] = await db + .select({ count: sql`COUNT(*)` }) + .from(transcriptions) + .where(eq(transcriptions.isDefault, true)); + + return NextResponse.json({ + data: sampleTranscriptions, + total: count, + page, + limit, + totalPages: Math.ceil(count / limit), + }); +}); diff --git a/app/src/app/api/admin/users/[id]/route.ts b/app/src/app/api/admin/users/[id]/route.ts new file mode 100644 index 0000000..edc6890 --- /dev/null +++ b/app/src/app/api/admin/users/[id]/route.ts @@ -0,0 +1,81 @@ +import { db } from "@/db"; +import { subscriptionTable, transcriptions, userTable } from "@/db/schema"; +import { withAdmin } from "@/lib/withAdmin"; +import { eq, sql } from "drizzle-orm"; +import { NextRequest, NextResponse } from "next/server"; +import { ROLES } from "@/constants/roles"; + +// GET /api/admin/users/:id +export const GET = withAdmin(async function ( + _req: NextRequest, + { params }: { params: { id: string } } +) { + const { id } = params; + + // Fetch user with subscription info + const [user] = await db + .select({ + id: userTable.id, + username: userTable.username, + name: userTable.name, + contactNumber: userTable.contactNumber, + role: userTable.role, + subscription: { + name: subscriptionTable.name, + recordingCount: subscriptionTable.recordingCount, + fileSizeLimitMB: subscriptionTable.fileSizeLimitMB, + durationDays: subscriptionTable.durationDays, + }, + }) + .from(userTable) + .leftJoin( + subscriptionTable, + eq(userTable.subscriptionId, subscriptionTable.id) + ) + .where(eq(userTable.id, id)); + + if (!user) { + return NextResponse.json({ error: "User not found" }, { status: 404 }); + } + + // Count user’s personal (non-default) transcriptions + const [{ count: usedCount }] = await db + .select({ count: sql`COUNT(*)` }) + .from(transcriptions) + .where(eq(transcriptions.userID, id)); + + // Calculate remaining recordings + const limit = user.subscription?.recordingCount ?? 0; + const remaining = Math.max(limit - usedCount, 0); + + return NextResponse.json({ + ...user, + recordingsUsed: usedCount, + recordingsRemaining: remaining, + }); +}); +// PATCH /api/admin/users/:id +export const PATCH = withAdmin(async function ( + req: NextRequest, + { params }: { params: { id: string } } +) { + const { id } = params; + const data = await req.json(); + + // Optional: Validate `role` if it's present in the payload + if (data.role && ![ROLES.ADMIN, ROLES.USER].includes(data.role)) { + return NextResponse.json({ error: "Invalid role" }, { status: 400 }); + } + + const [updated] = await db + .update(userTable) + .set(data) + .where(eq(userTable.id, id)) + .returning(); + + if (!updated) { + return NextResponse.json({ error: "User not found" }, { status: 404 }); + } + const { password_hash, ...user } = updated; // removed password hash from response + return NextResponse.json(user); +}); diff --git a/app/src/app/api/admin/users/route.ts b/app/src/app/api/admin/users/route.ts new file mode 100644 index 0000000..e5cfbf9 --- /dev/null +++ b/app/src/app/api/admin/users/route.ts @@ -0,0 +1,58 @@ +import { validateRequest } from "@/auth"; +import { db } from "@/db"; +import { userTable, subscriptionTable } from "@/db/schema"; +import { withAdmin } from "@/lib/withAdmin"; +import { ilike, eq, sql, or } from "drizzle-orm"; +import { NextRequest, NextResponse } from "next/server"; + +export const GET = withAdmin(async function (req: NextRequest) { + const { user } = await validateRequest(); + + if (!user || user.role !== "ADMIN") { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + const { searchParams } = new URL(req.url); + const page = parseInt(searchParams.get("page") || "1"); + const limit = parseInt(searchParams.get("limit") || "10"); + const search = searchParams.get("search")?.trim().toLowerCase() || ""; + + const offset = (page - 1) * limit; + + const whereClause = search + ? or( + ilike(userTable.username, `%${search}%`), + ilike(userTable.name, `%${search}%`), + ilike(userTable.contactNumber, `%${search}%`) + ) + : undefined; + + const users = await db + .select({ + id: userTable.id, + username: userTable.username, + name: userTable.name, + contactNumber: userTable.contactNumber, + role: userTable.role, + subscription: { + name: subscriptionTable.name, + recordingCount: subscriptionTable.recordingCount, + fileSizeLimitMB: subscriptionTable.fileSizeLimitMB, + durationDays: subscriptionTable.durationDays, + }, + }) + .from(userTable) + .leftJoin( + subscriptionTable, + eq(userTable.subscriptionId, subscriptionTable.id) + ) + .where(whereClause) + .limit(limit) + .offset(offset); + + const [{ count }] = await db + .select({ count: sql`COUNT(*)` }) + .from(userTable) + .where(whereClause); + + return NextResponse.json({ users, total: count, page, limit }); +}); diff --git a/app/src/app/api/admin/users/transcriptions/[userId]/route.ts b/app/src/app/api/admin/users/transcriptions/[userId]/route.ts new file mode 100644 index 0000000..5406459 --- /dev/null +++ b/app/src/app/api/admin/users/transcriptions/[userId]/route.ts @@ -0,0 +1,56 @@ +import { db } from "@/db"; +import { transcriptions, userTable } from "@/db/schema"; +import { withAdmin } from "@/lib/withAdmin"; +import { and, eq, sql } from "drizzle-orm"; +import { NextRequest, NextResponse } from "next/server"; + +export const GET = withAdmin(async function ( + req: NextRequest, + { params }: { params: { userId: string } } +) { + const { searchParams } = new URL(req.url); + const page = parseInt(searchParams.get("page") || "1"); + const limit = parseInt(searchParams.get("limit") || "10"); + const offset = (page - 1) * limit; + const userId = params.userId; + console.log("userId is ", userId); + // Validate user exists + const [user] = await db + .select({ id: userTable.id }) + .from(userTable) + .where(eq(userTable.id, userId)); + + if (!user) { + return NextResponse.json({ error: "User not found" }, { status: 404 }); + } + + // Fetch paginated transcriptions + const transcriptionsList = await db + .select({ + id: transcriptions.id, + documentName: transcriptions.documentName, + translation: transcriptions.translation, + summary: transcriptions.summary, + createdAt: transcriptions.createdAt, + audioDuration: transcriptions.audioDuration, + isDefault: transcriptions.isDefault, + }) + .from(transcriptions) + .where(eq(transcriptions.userID, userId)) + .limit(limit) + .offset(offset); + + // Count total records + const [{ count }] = await db + .select({ count: sql`COUNT(*)` }) + .from(transcriptions) + .where(eq(transcriptions.userID, userId)); + + return NextResponse.json({ + data: transcriptionsList, + total: count, + page, + limit, + totalPages: Math.ceil(count / limit), + }); +}); diff --git a/app/src/app/api/admin/users/update-role/route.ts b/app/src/app/api/admin/users/update-role/route.ts new file mode 100644 index 0000000..165de7c --- /dev/null +++ b/app/src/app/api/admin/users/update-role/route.ts @@ -0,0 +1,40 @@ +import { ROLES } from "@/constants/roles"; +import { db } from "@/db"; +import { userTable } from "@/db/schema"; +import { withAdmin } from "@/lib/withAdmin"; +import { eq } from "drizzle-orm"; +import { NextRequest, NextResponse } from "next/server"; + +export const PATCH = withAdmin(async function (req: NextRequest) { + const { userId, role } = await req.json(); + + const roles = [ROLES.ADMIN, ROLES.USER] as never as [string]; + if ( + typeof userId !== "string" || + typeof role !== "string" || + !roles.includes(role) + ) { + return NextResponse.json( + { error: "Invalid or missing parameters" }, + { status: 400 } + ); + } + + const [updatedUser] = await db + .update(userTable) + .set({ role }) + .where(eq(userTable.id, userId)) + .returning({ + id: userTable.id, + username: userTable.username, + name: userTable.name, + contactNumber: userTable.contactNumber, + role: userTable.role, + }); + + if (!updatedUser) { + return NextResponse.json({ error: "User not found" }, { status: 404 }); + } + + return NextResponse.json({ user: updatedUser }); +}); diff --git a/app/src/app/api/admin/users/update-subscription/route.ts b/app/src/app/api/admin/users/update-subscription/route.ts new file mode 100644 index 0000000..d26c164 --- /dev/null +++ b/app/src/app/api/admin/users/update-subscription/route.ts @@ -0,0 +1,28 @@ +import { db } from "@/db"; +import { userTable } from "@/db/schema"; +import { withAdmin } from "@/lib/withAdmin"; +import { eq } from "drizzle-orm"; +import { NextRequest, NextResponse } from "next/server"; + +export const PATCH = withAdmin(async function (req: NextRequest) { + const { userId, subscriptionId } = await req.json(); + + if (!userId || !subscriptionId) { + return NextResponse.json({ error: "Missing parameters" }, { status: 400 }); + } + + const [updatedUser] = await db + .update(userTable) + .set({ subscriptionId }) + .where(eq(userTable.id, userId)) + .returning(); + + if (!updatedUser) { + return NextResponse.json( + { error: "User not found or not updated" }, + { status: 404 } + ); + } + + return NextResponse.json({ user: updatedUser }); +}); diff --git a/app/src/app/api/signin/route.ts b/app/src/app/api/signin/route.ts index 3c9c1aa..c8b31fc 100644 --- a/app/src/app/api/signin/route.ts +++ b/app/src/app/api/signin/route.ts @@ -8,67 +8,74 @@ import { cookies } from "next/headers"; import { z } from "zod"; export async function POST(req: Request) { - try { - const body = await req.json(); - const { password, userEmail } = signinUserSchema.parse(body); + try { + const body = await req.json(); + const { password, userEmail } = signinUserSchema.parse(body); - // Check if user exists - const existingUser = await db - .select() - .from(userTable) - .where(eq(userTable.username, userEmail)); + // Check if user exists + const existingUser = await db + .select() + .from(userTable) + .where(eq(userTable.username, userEmail)); - if (!existingUser[0]) { - return new Response("User not found", { - status: 404, - }); - } - - const user = existingUser[0]; - - // Verify password - const validPassword = await verify(user.password_hash, password, { - memoryCost: 19456, - timeCost: 2, - outputLen: 32, - parallelism: 1 - }); + if (!existingUser[0]) { + return new Response("User not found", { + status: 404, + }); + } - if (!validPassword) { - return new Response("Incorrect username or password", { status: 401 }); - } + const user = existingUser[0]; - // Create session and set cookie - const session = await lucia.createSession(user.id, {}); - const sessionCookie = lucia.createSessionCookie(session.id); - (await cookies()).set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes); + // Verify password + const validPassword = await verify(user.password_hash, password, { + memoryCost: 19456, + timeCost: 2, + outputLen: 32, + parallelism: 1, + }); - // Check if bot is added - const bot = await db - .select() - .from(botTable) - .where(eq(botTable.userId, user.id)); + if (!validPassword) { + return new Response("Incorrect username or password", { status: 401 }); + } - const isBotAdded = !!bot[0]; + // Create session and set cookie + const session = await lucia.createSession(user.id, {}); + const sessionCookie = lucia.createSessionCookie(session.id); + (await cookies()).set( + sessionCookie.name, + sessionCookie.value, + sessionCookie.attributes + ); - // Respond with success and isBotAdded flag - return new Response(JSON.stringify({ - message: "User Logged In", - data: { - isBotAdded - } - }), { - status: 200, - headers: { "Content-Type": "application/json" } - }); + // Check if bot is added + const bot = await db + .select() + .from(botTable) + .where(eq(botTable.userId, user.id)); - } catch (error) { - console.error(error); + const isBotAdded = !!bot[0]; - if (error instanceof z.ZodError) { - return new Response(error.message, { status: 422 }); - } + // Respond with success and isBotAdded flag + return new Response( + JSON.stringify({ + message: "User Logged In", + data: { + isBotAdded, + role: user.role || null, + }, + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ); + } catch (error) { + console.error(error); - return new Response("Failed to login user", { status: 500 }); + if (error instanceof z.ZodError) { + return new Response(error.message, { status: 422 }); } + + return new Response("Failed to login user", { status: 500 }); + } } diff --git a/app/src/auth.ts b/app/src/auth.ts index a4f1d58..c067cfe 100644 --- a/app/src/auth.ts +++ b/app/src/auth.ts @@ -20,6 +20,7 @@ export const lucia = new Lucia(adapter, { return { // attributes has the type of DatabaseUserAttributes username: attributes.username, + role: attributes.role, }; }, }); @@ -87,4 +88,5 @@ declare module "lucia" { interface DatabaseUserAttributes { username: string; + role: string; } diff --git a/app/src/constants/roles.ts b/app/src/constants/roles.ts new file mode 100644 index 0000000..fd5ee3f --- /dev/null +++ b/app/src/constants/roles.ts @@ -0,0 +1,7 @@ +// roles.ts +export const ROLES = { + USER: "USER", + ADMIN: "ADMIN", +} as const; + +export type Role = (typeof ROLES)[keyof typeof ROLES]; diff --git a/app/src/db/schema.ts b/app/src/db/schema.ts index 8567d10..23ac0d4 100644 --- a/app/src/db/schema.ts +++ b/app/src/db/schema.ts @@ -1,3 +1,4 @@ +import { ROLES } from "@/constants/roles"; import { segment } from "@/types/transcriptions"; import { timestamp, @@ -43,7 +44,7 @@ export const userTable = pgTable("user", { subscriptionId: text("subscriptionId") .notNull() .references(() => subscriptionTable.id), - role: text("role"), + role: text("role").notNull().default(ROLES.USER), createdAt: timestamp("createdAt", { mode: "date" }).defaultNow(), }); @@ -83,6 +84,7 @@ export const subscriptionTable = pgTable("subscriptions", { recordingCount: integer("recordingCount").notNull(), fileSizeLimitMB: integer("fileSizeLimitMB").notNull(), // Store all sizes in MB durationDays: integer("durationDays").notNull(), // Validity in days + price: integer("price").notNull().default(0), createdAt: timestamp("createdAt", { mode: "date" }).defaultNow(), }); diff --git a/app/src/hooks/useUser.ts b/app/src/hooks/useUser.ts index 8a55250..cb7beff 100644 --- a/app/src/hooks/useUser.ts +++ b/app/src/hooks/useUser.ts @@ -7,6 +7,7 @@ import { useRouter } from "next/navigation"; import { SigninUserRequest, SignupUserRequest } from "@/Validators/register"; import { useState } from "react"; import Cookies from "js-cookie"; +import { ROLES } from "@/constants/roles"; export const useUser = () => { const router = useRouter(); @@ -76,7 +77,7 @@ export const useUser = () => { // Wait for the toast to be shown a bit before redirect setTimeout(() => { - router.push("/new"); // Navigate to /new + router.push(res.data.role === ROLES.USER ? "/new" : "/admin/users"); // Navigate to /new if role is USER else navigate to admin route router.refresh(); // Force a layout/server refresh }, 100); // Adjust timing if needed }, diff --git a/app/src/lib/axios.ts b/app/src/lib/axios.ts new file mode 100644 index 0000000..892c487 --- /dev/null +++ b/app/src/lib/axios.ts @@ -0,0 +1,9 @@ +// Creating common axios client for api calls +import axios from "axios"; + +export const API = axios.create({ + baseURL: "/api", + headers: { + "Content-Type": "application/json", + }, +}); diff --git a/app/src/lib/withAdmin.ts b/app/src/lib/withAdmin.ts new file mode 100644 index 0000000..4656912 --- /dev/null +++ b/app/src/lib/withAdmin.ts @@ -0,0 +1,17 @@ +import { validateRequest } from "@/auth"; +import { NextResponse } from "next/server"; +import type { NextRequest } from "next/server"; + +export function withAdmin< + T extends (req: NextRequest, ctx: { params: any }) => Promise +>(handler: T) { + return async (req: NextRequest, context: { params: any }) => { + const { user } = await validateRequest(); + + if (!user || user.role !== "ADMIN") { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + return handler(req, context); // ✅ Pass context (params) to your handler + }; +}