diff --git a/actions/omada-actions.ts b/actions/omada-actions.ts index 0fa8cd1..a4d78dd 100644 --- a/actions/omada-actions.ts +++ b/actions/omada-actions.ts @@ -1,39 +1,9 @@ -interface IpAddress { - ip: string; - mask: number; -} +"use server"; -interface Ipv6Address { - ip: string; - prefix: number; -} - -interface MacAddress { - ruleId?: number; - name: string; - macAddress: string; -} - -interface GroupProfile { - groupId: string; - site?: string; - name: string; - buildIn?: boolean; - ipList?: IpAddress[]; - ipv6List?: Ipv6Address[]; - macAddressList?: MacAddress[]; - count: number; - type: number; - resource: number; -} - -interface OmadaResponse { - errorCode: number; - msg: string; - result: { - data: GroupProfile[]; - }; -} +import prisma from "@/lib/db"; +import type { GroupProfile, MacAddress, OmadaResponse } from "@/lib/types"; +import { formatMacAddress } from "@/lib/utils"; +import { revalidatePath } from "next/cache"; async function fetchOmadaGroupProfiles(siteId: string): Promise { if (!siteId) { @@ -68,7 +38,7 @@ async function fetchOmadaGroupProfiles(siteId: string): Promise { } } -export { fetchOmadaGroupProfiles, type MacAddress }; +export { fetchOmadaGroupProfiles }; export async function addDevicesToGroup({ siteId, @@ -129,7 +99,7 @@ export async function addDevicesToGroup({ headers: headers, body: JSON.stringify(requestBody), }); - console.log(response); + console.log(response.status); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } @@ -138,3 +108,48 @@ export async function addDevicesToGroup({ throw error instanceof Error ? error : new Error("Unknown error occurred"); } } + +export async function blockDevice({ + macAddress, + type, +}: { macAddress: string; type: "block" | "unblock" }) { + console.log("hello world asdasd"); + if (!macAddress) { + throw new Error("macAddress is a required parameter"); + } + const device = await prisma.device.findFirst({ + where: { + mac: macAddress, + }, + }); + try { + const baseUrl: string = process.env.OMADA_BASE_URL || ""; + const url: string = `${baseUrl}/api/v2/sites/${process.env.OMADA_SITE_ID}/cmd/clients/${formatMacAddress(macAddress)}/${type}`; + console.log(url); + const headers: HeadersInit = { + "X-API-key": process.env.OMADA_PROXY_API_KEY || "", + }; + + const response = await fetch(url, { + method: "POST", + headers: headers, + }); + console.log("blocking..."); + console.log(response); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + await prisma.device.update({ + where: { + id: device?.id, + }, + data: { + blocked: type === "block", + }, + }); + revalidatePath("/parental-control"); + } catch (error) { + console.error("Error blocking device:", error); + throw error instanceof Error ? error : new Error("Unknown error occurred"); + } +} diff --git a/actions/payment.ts b/actions/payment.ts index b842ba8..e51afd6 100644 --- a/actions/payment.ts +++ b/actions/payment.ts @@ -72,6 +72,17 @@ export async function verifyPayment(data: VerifyPaymentType) { }, data: { paid: true, + paidAt: new Date(), + devices: { + updateMany: { + where: { + paymentId: payment?.id, + }, + data: { + isActive: true, + }, + }, + }, }, }), ]); diff --git a/app/(dashboard)/parental-control/page.tsx b/app/(dashboard)/parental-control/page.tsx new file mode 100644 index 0000000..e307cb6 --- /dev/null +++ b/app/(dashboard)/parental-control/page.tsx @@ -0,0 +1,56 @@ +import { DevicesTable } from "@/components/devices-table"; +import Filter from "@/components/filter"; +import Search from "@/components/search"; +import { AArrowDown, AArrowUp } from "lucide-react"; +import React, { Suspense } from "react"; + +const sortfilterOptions = [ + { + value: 'asc', + label: 'Ascending', + icon: , + }, + { + value: 'desc', + label: 'Descending', + icon: , + }, +] + + +export default async function ParentalControl({ + searchParams, +}: { + searchParams: Promise<{ + query: string; + page: number; + sortBy: string; + status: string; + }>; +}) { + const query = (await searchParams)?.query || ""; + return ( +
+
+

+ Parental Control +

+
+ +
+ + +
+ + + +
+ ); +} diff --git a/app/api/check-devices/route.ts b/app/api/check-devices/route.ts new file mode 100644 index 0000000..6735911 --- /dev/null +++ b/app/api/check-devices/route.ts @@ -0,0 +1,145 @@ +import { blockDevice } from "@/actions/omada-actions"; +import prisma from "@/lib/db"; +import { validateApiKey } from "@/lib/utils"; +import { addDays, addMonths, isAfter, isWithinInterval } from "date-fns"; + +const lastRunTime = new Date(); + +export async function GET(request: Request) { + try { + // Validate API key before proceeding + validateApiKey(request); + + const currentTime = new Date(); + const hoursSinceLastRun = + (currentTime.getTime() - lastRunTime.getTime()) / (1000 * 60 * 60); + + // Get all active and unblocked devices with their latest payment + const devices = await prisma.device.findMany({ + where: { + isActive: true, + blocked: false, + }, + include: { + payment: true, + User: true, + }, + }); + let devicesNeedingNotification = 0; + let devicesBlocked = 0; + + for (const device of devices) { + let expiryDate = new Date(); + + const payment = device.payment; + expiryDate = addMonths( + payment?.paidAt || new Date(), + payment?.numberOfMonths || 0, + ); + + // Calculate notification threshold (5 days before expiry) + const notificationThreshold = addDays(expiryDate, -5); + + const currentDate = new Date(); + + console.log("device name -> ", device.name); + console.log("paid date -> ", device.payment?.paidAt); + console.log("no of months paid -> ", device.payment?.numberOfMonths); + console.log("calculated expire date -> ", expiryDate); + console.log("notification threshold -> ", notificationThreshold); + console.log("current date -> ", currentDate); + + // Check if device is within notification period + if ( + isWithinInterval(currentDate, { + start: notificationThreshold, + end: expiryDate, + }) + ) { + // Device is within 5 days of expiring + if (device.User?.phoneNumber) { + await sendNotifySms( + new Date(expiryDate), + device.User.phoneNumber, + device.name, + ); + devicesNeedingNotification++; + } + } + + // Check if device has expired + if (isAfter(currentDate, expiryDate)) { + // Device has expired, block it + await prisma.device.update({ + where: { id: device.id }, + data: { + isActive: false, + blocked: true, + }, + }); + devicesBlocked++; + } + await blockDevice({ + macAddress: device.mac, + type: "block", + }); + } + + if (hoursSinceLastRun < 24) { + return Response.json({ + totalActiveDevices: devices.length, + devicesChecked: { + notified: devicesNeedingNotification, + blocked: devicesBlocked, + }, + message: "Check was run recently", + nextCheckIn: `${Math.round(24 - hoursSinceLastRun)} hours`, + }); + } + + return Response.json({ + success: true, + totalActiveDevices: devices.length, + devicesChecked: { + notified: devicesNeedingNotification, + blocked: devicesBlocked, + }, + runAt: currentTime, + }); + } catch (error) { + if (error instanceof Error) { + if (error.message === "API key is missing") { + return Response.json({ error: "API key is required" }, { status: 401 }); + } + if (error.message === "Invalid API key") { + return Response.json({ error: "Invalid API key" }, { status: 403 }); + } + } + + console.error("Error in device check:", error); + return Response.json({ error: "Failed to check devices" }, { status: 500 }); + } +} + +// Mock function - replace with your actual SMS implementation +async function sendNotifySms( + expireDate: Date, + phoneNumber: string, + deviceName?: string, +) { + const respose = await fetch("https://smsapi.sarlink.link/send", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + api_key: process.env.SMS_API_KEY, + number: phoneNumber, + text: `REMINDER! Your device [${deviceName}] will expire on ${new Date(expireDate)}.`, + }), + }); + const data = await respose.json(); + console.log(data); + return data; + // Implement your SMS logic here +} diff --git a/components/block-device-button.tsx b/components/block-device-button.tsx new file mode 100644 index 0000000..b9e2522 --- /dev/null +++ b/components/block-device-button.tsx @@ -0,0 +1,34 @@ +'use client' + +import { blockDevice } from "@/actions/omada-actions"; +import { Button } from "@/components/ui/button"; +import type { Device } from "@prisma/client"; +import { useState } from "react"; +import { toast } from "sonner"; +import { TextShimmer } from "./ui/text-shimmer"; + +export default function BlockDeviceButton({ device }: { device: Device }) { + const [disabled, setDisabled] = useState(false); + return ( + + ) +} \ No newline at end of file diff --git a/components/devices-table.tsx b/components/devices-table.tsx index 6cbdc79..205e999 100644 --- a/components/devices-table.tsx +++ b/components/devices-table.tsx @@ -8,25 +8,34 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; +import { auth } from "@/lib/auth"; import prisma from "@/lib/db"; +import { headers } from "next/headers"; import Link from "next/link"; import AddDevicesToCartButton from "./add-devices-to-cart-button"; +import BlockDeviceButton from "./block-device-button"; import Pagination from "./pagination"; export async function DevicesTable({ searchParams, + parentalControl }: { searchParams: Promise<{ query: string; page: number; sortBy: string; }>; + parentalControl?: boolean; }) { + const session = await auth.api.getSession({ + headers: await headers() + }) const query = (await searchParams)?.query || ""; const page = (await searchParams)?.page; const sortBy = (await searchParams)?.sortBy || "asc"; const totalDevices = await prisma.device.count({ where: { + userId: session?.session.userId, OR: [ { name: { @@ -46,6 +55,7 @@ export async function DevicesTable({ paid: false } }, + isActive: parentalControl ? parentalControl : undefined, }, }); @@ -55,6 +65,7 @@ export async function DevicesTable({ const devices = await prisma.device.findMany({ where: { + userId: session?.session.userId, OR: [ { name: { @@ -74,6 +85,7 @@ export async function DevicesTable({ paid: false } }, + isActive: parentalControl, }, skip: offset, @@ -123,7 +135,11 @@ export async function DevicesTable({ {device.mac} - + {!parentalControl ? ( + + ) : ( + + )} ))} diff --git a/components/payments-table.tsx b/components/payments-table.tsx index 22aa747..c99eec0 100644 --- a/components/payments-table.tsx +++ b/components/payments-table.tsx @@ -11,8 +11,10 @@ import { import prisma from "@/lib/db"; import Link from "next/link"; +import { auth } from "@/lib/auth"; import { cn } from "@/lib/utils"; import { Calendar } from "lucide-react"; +import { headers } from "next/headers"; import Pagination from "./pagination"; import { Badge } from "./ui/badge"; import { Button } from "./ui/button"; @@ -26,10 +28,14 @@ export async function PaymentsTable({ sortBy: string; }>; }) { + const session = await auth.api.getSession({ + headers: await headers() + }) const query = (await searchParams)?.query || ""; const page = (await searchParams)?.page; const totalPayments = await prisma.payment.count({ where: { + userId: session?.session.userId, OR: [ { devices: { @@ -51,6 +57,7 @@ export async function PaymentsTable({ const payments = await prisma.payment.findMany({ where: { + userId: session?.session.userId, OR: [ { devices: { diff --git a/components/ui/app-sidebar.tsx b/components/ui/app-sidebar.tsx index c022656..162c707 100644 --- a/components/ui/app-sidebar.tsx +++ b/components/ui/app-sidebar.tsx @@ -40,12 +40,16 @@ const data = { url: "/devices", icon: , }, - { title: "Payments", url: "/payments", icon: , }, + { + title: "Parental Control", + url: "/parental-control", + icon: , + }, { title: "Agreements", url: "/agreements", diff --git a/components/ui/text-shimmer.tsx b/components/ui/text-shimmer.tsx new file mode 100644 index 0000000..a729c16 --- /dev/null +++ b/components/ui/text-shimmer.tsx @@ -0,0 +1,55 @@ +'use client'; +import React, { useMemo, type JSX } from 'react'; +import { motion } from 'motion/react'; +import { cn } from '@/lib/utils'; + +interface TextShimmerProps { + children: string; + as?: React.ElementType; + className?: string; + duration?: number; + spread?: number; +} + +export function TextShimmer({ + children, + as: Component = 'p', + className, + duration = 2, + spread = 2, +}: TextShimmerProps) { + const MotionComponent = motion.create( + Component as keyof JSX.IntrinsicElements + ); + + const dynamicSpread = useMemo(() => { + return children.length * spread; + }, [children, spread]); + + return ( + + {children} + + ); +} diff --git a/lib/types.ts b/lib/types.ts index 053fdea..c76e977 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -5,3 +5,40 @@ export type PaymentType = { amount: number; paid: boolean; }; + +interface IpAddress { + ip: string; + mask: number; +} + +interface Ipv6Address { + ip: string; + prefix: number; +} + +export interface MacAddress { + ruleId?: number; + name: string; + macAddress: string; +} + +export interface GroupProfile { + groupId: string; + site?: string; + name: string; + buildIn?: boolean; + ipList?: IpAddress[]; + ipv6List?: Ipv6Address[]; + macAddressList?: MacAddress[]; + count: number; + type: number; + resource: number; +} + +export interface OmadaResponse { + errorCode: number; + msg: string; + result: { + data: GroupProfile[]; + }; +} diff --git a/lib/utils.ts b/lib/utils.ts index 90fd3c7..dcaafd8 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -26,3 +26,23 @@ export const formatMacAddress = (mac: string): string => { // Provide a fallback if formatted is null return formatted ? formatted.join("-") : ""; }; + +export function validateApiKey(request: Request) { + // Get API key from environment variable + const validApiKey = process.env.CRON_API_KEY; + + if (!validApiKey) { + throw new Error("CRON_API_KEY is not configured"); + } + + // Get API key from request header + const apiKey = request.headers.get("x-api-key"); + + if (!apiKey) { + throw new Error("API key is missing"); + } + + if (apiKey !== validApiKey) { + throw new Error("Invalid API key"); + } +} diff --git a/package-lock.json b/package-lock.json index 52a8c3c..c73139c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,6 +31,7 @@ "date-fns": "^4.1.0", "jotai": "^2.8.0", "lucide-react": "^0.460.0", + "motion": "^11.15.0", "next": "15.0.3", "next-themes": "^0.4.3", "nextjs-toploader": "^3.7.15", @@ -6486,6 +6487,32 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/framer-motion": { + "version": "11.15.0", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-11.15.0.tgz", + "integrity": "sha512-MLk8IvZntxOMg7lDBLw2qgTHHv664bYoYmnFTmE0Gm/FW67aOJk0WM3ctMcG+Xhcv+vh5uyyXwxvxhSeJzSe+w==", + "dependencies": { + "motion-dom": "^11.14.3", + "motion-utils": "^11.14.3", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, "node_modules/fs-monkey": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/fs-monkey/-/fs-monkey-1.0.6.tgz", @@ -7604,6 +7631,41 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/motion": { + "version": "11.15.0", + "resolved": "https://registry.npmjs.org/motion/-/motion-11.15.0.tgz", + "integrity": "sha512-iZ7dwADQJWGsqsSkBhNHdI2LyYWU+hA1Nhy357wCLZq1yHxGImgt3l7Yv0HT/WOskcYDq9nxdedyl4zUv7UFFw==", + "dependencies": { + "framer-motion": "^11.15.0", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/motion-dom": { + "version": "11.14.3", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-11.14.3.tgz", + "integrity": "sha512-lW+D2wBy5vxLJi6aCP0xyxTxlTfiu+b+zcpVbGVFUxotwThqhdpPRSmX8xztAgtZMPMeU0WGVn/k1w4I+TbPqA==" + }, + "node_modules/motion-utils": { + "version": "11.14.3", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-11.14.3.tgz", + "integrity": "sha512-Xg+8xnqIJTpr0L/cidfTTBFkvRw26ZtGGuIhA94J9PQ2p4mEa06Xx7QVYZH0BP+EpMSaDlu+q0I0mmvwADPsaQ==" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", diff --git a/package.json b/package.json index bd7a689..003fc08 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "date-fns": "^4.1.0", "jotai": "^2.8.0", "lucide-react": "^0.460.0", + "motion": "^11.15.0", "next": "15.0.3", "next-themes": "^0.4.3", "nextjs-toploader": "^3.7.15", diff --git a/prisma/migrations/20241221120035_add/migration.sql b/prisma/migrations/20241221120035_add/migration.sql new file mode 100644 index 0000000..9722a88 --- /dev/null +++ b/prisma/migrations/20241221120035_add/migration.sql @@ -0,0 +1,11 @@ +/* + Warnings: + + - You are about to drop the column `firstPaymentDone` on the `user` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "Device" ADD COLUMN "registered" BOOLEAN NOT NULL DEFAULT false; + +-- AlterTable +ALTER TABLE "user" DROP COLUMN "firstPaymentDone"; diff --git a/prisma/migrations/20241222124118_add/migration.sql b/prisma/migrations/20241222124118_add/migration.sql new file mode 100644 index 0000000..619dd9e --- /dev/null +++ b/prisma/migrations/20241222124118_add/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Device" ADD COLUMN "blocked" BOOLEAN NOT NULL DEFAULT false; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 91199ad..0c15ecd 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -14,19 +14,19 @@ datasource db { } model User { - id String @id @default(cuid()) - name String? - email String? @unique - emailVerified Boolean @default(false) - firstPaymentDone Boolean @default(false) - verified Boolean @default(false) - accNo String? + id String @id @default(cuid()) + name String? + email String? @unique + emailVerified Boolean @default(false) + + verified Boolean @default(false) + accNo String? // island String? - address String? - id_card String? @unique - dob DateTime? - atoll Atoll? @relation(fields: [atollId], references: [id]) - island Island? @relation(fields: [islandId], references: [id]) + address String? + id_card String? @unique + dob DateTime? + atoll Atoll? @relation(fields: [atollId], references: [id]) + island Island? @relation(fields: [islandId], references: [id]) image String? createdAt DateTime @default(now()) @@ -114,6 +114,8 @@ model Device { name String mac String isActive Boolean @default(false) + registered Boolean @default(false) + blocked Boolean @default(false) expiryDate DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/prisma/seed.ts b/prisma/seed.ts index cf33f8d..d59beed 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -1,4 +1,3 @@ -import { faker } from "@faker-js/faker"; import { PrismaClient } from "@prisma/client"; const prisma = new PrismaClient(); @@ -12,12 +11,11 @@ async function main() { }, update: {}, create: { - name: "Admin", - email: "admin@sarlink.net", + name: "Admin Admin", + email: "admin@example.com", emailVerified: true, - firstPaymentDone: true, verified: true, - address: "Dharanboodhoo", + address: "Sky villa", id_card: "A265117", dob: new Date("1990-01-01"), phoneNumber: "+9607780588", @@ -25,38 +23,7 @@ async function main() { role: "ADMIN", }, }); - const users = Array.from({ length: 25 }, () => ({ - name: `${faker.person.fullName().split(" ")[1]} House-${crypto - .randomUUID() - .slice(0, 5)}`, - email: faker.internet.email(), - emailVerified: false, - firstPaymentDone: false, - verified: false, - address: faker.location.streetAddress(), - id_card: `A${Math.round(Math.random() * 999999)}`, - dob: faker.date.between({ - from: "1900-01-01", - to: "2000-01-01", - }), - phoneNumber: String(faker.number.int({ min: 7000000, max: 9999999 })), - phoneNumberVerified: false, - role: "USER", - })); - const seedUsers = await Promise.all( - users.map((user) => prisma.user.create({ data: user })), - ); - - const FAKE_DEVICES = Array.from({ length: 25 }, () => ({ - name: faker.commerce.productName(), - mac: faker.internet.mac(), - userId: seedUsers[Math.floor(Math.random() * seedUsers.length)].id, - })); - - await prisma.device.createMany({ - data: FAKE_DEVICES, - }); const FAAFU_ATOLL = await prisma.atoll.create({ data: { name: "F",