diff --git a/prisma/migrations/20240215193803_add_referral_link_request_and_referral_link/migration.sql b/prisma/migrations/20240215193803_add_referral_link_request_and_referral_link/migration.sql new file mode 100644 index 0000000..5838fff --- /dev/null +++ b/prisma/migrations/20240215193803_add_referral_link_request_and_referral_link/migration.sql @@ -0,0 +1,30 @@ +-- CreateTable +CREATE TABLE `Office365LinkRequest` ( + `id` VARCHAR(191) NOT NULL, + `status` ENUM('WAITING', 'ACCEPTED', 'CANCELLED', 'REJECTED') NOT NULL DEFAULT 'WAITING', + `requestedAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `acceptedAt` DATETIME(3) NULL, + `cancelledAt` DATETIME(3) NULL, + `rejectedAt` DATETIME(3) NULL, + `createdBy` VARCHAR(191) NOT NULL, + + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `Office365ReferralLink` ( + `id` VARCHAR(191) NOT NULL, + `email` VARCHAR(191) NOT NULL, + `activePeriod` VARCHAR(191) NOT NULL, + `numberOfUsers` INTEGER NOT NULL, + `link` VARCHAR(191) NULL, + `requestId` VARCHAR(191) NOT NULL, + + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- AddForeignKey +ALTER TABLE `Office365LinkRequest` ADD CONSTRAINT `Office365LinkRequest_createdBy_fkey` FOREIGN KEY (`createdBy`) REFERENCES `User`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `Office365ReferralLink` ADD CONSTRAINT `Office365ReferralLink_requestId_fkey` FOREIGN KEY (`requestId`) REFERENCES `Office365LinkRequest`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 05bd2c1..4f73773 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -16,6 +16,7 @@ model User { photoProfile String? directPermissions Permission[] @relation("PermissionToUser") roles Role[] @relation("RoleToUser") + linkRequests Office365LinkRequest[] } model Role { @@ -37,3 +38,32 @@ model Permission { roles Role[] @relation("PermissionToRole") directUsers User[] @relation("PermissionToUser") } + +enum Office365LinkRequestStatus { + WAITING + ACCEPTED + CANCELLED + REJECTED +} + +model Office365LinkRequest { + id String @id @default(cuid()) + creator User @relation(fields: [createdBy], references: [id]) + status Office365LinkRequestStatus @default(WAITING) + requestedAt DateTime @default(now()) + acceptedAt DateTime? + cancelledAt DateTime? + rejectedAt DateTime? + createdBy String + links Office365ReferralLink[] +} + +model Office365ReferralLink { + id String @id @default(cuid()) + request Office365LinkRequest @relation(fields: [requestId], references: [id]) + email String + activePeriod String + numberOfUsers Int + link String? + requestId String +} \ No newline at end of file diff --git a/prisma/seeds/permissionSeed.ts b/prisma/seeds/permissionSeed.ts index 668aded..2050aeb 100644 --- a/prisma/seeds/permissionSeed.ts +++ b/prisma/seeds/permissionSeed.ts @@ -81,6 +81,18 @@ export default async function permissionSeed(prisma: PrismaClient) { description: "Allows deleting a user", isActive: true, }, + { + code: "office-365-request.create", + name: "Create Office 365 Request", + description: "Allows create an Office 365 Reseller Request", + isActive: true + }, + { + code: "office-365-request.getMine", + name: "Get my Office 365 Requests", + description: "Allows retrieve user's Office 365 Link Requests", + isActive: true + } ]; await Promise.all( diff --git a/src/app/dashboard/reseller-office-365/request/page.tsx b/src/app/dashboard/reseller-office-365/request/page.tsx index 2db2afb..dea4d45 100644 --- a/src/app/dashboard/reseller-office-365/request/page.tsx +++ b/src/app/dashboard/reseller-office-365/request/page.tsx @@ -1,6 +1,7 @@ import getUserRoles from "@/modules/auth/utils/getUserRoles"; import checkMultiplePermissions from "@/modules/dashboard/services/checkMultiplePermissions"; import checkPermission from "@/modules/dashboard/services/checkPermission"; +import getLinkRequests from "@/modules/resellerOffice365/actions/getLinkRequests"; import RequestTable from "@/modules/resellerOffice365/tables/RequestTable/RequestTable"; import { Card, Stack, Title } from "@mantine/core"; import { notFound } from "next/navigation"; @@ -17,11 +18,19 @@ export default async function RequestLinkPage() { if (!permissions.readAll) notFound(); + const data = await getLinkRequests() + if (!data.success){ + //todo: handle error + console.error(data.error) + throw new Error("Error while fetch permission") + } + const tableData = data.data + return ( Permohonan Link Office 365 - + ); diff --git a/src/modules/dashboard/errors/DashboardError.ts b/src/modules/dashboard/errors/DashboardError.ts index 070d506..af3f052 100644 --- a/src/modules/dashboard/errors/DashboardError.ts +++ b/src/modules/dashboard/errors/DashboardError.ts @@ -7,6 +7,7 @@ export const DashboardErrorCodes = [ "INVALID_JWT_TOKEN", "JWT_SECRET_EMPTY", "USER_ALREADY_EXISTS", + "INVALID_FORM_DATA" ] as const; interface DashboardErrorOptions { diff --git a/src/modules/resellerOffice365/actions/createLinkRequest.ts b/src/modules/resellerOffice365/actions/createLinkRequest.ts new file mode 100644 index 0000000..16fdd6b --- /dev/null +++ b/src/modules/resellerOffice365/actions/createLinkRequest.ts @@ -0,0 +1,65 @@ +"use server"; +import checkPermission from "@/modules/dashboard/services/checkPermission"; +import ServerResponseAction from "@/modules/dashboard/types/ServerResponseAction"; +import handleCatch from "@/modules/dashboard/utils/handleCatch"; +import unauthorized from "@/modules/dashboard/utils/unauthorized"; +import "server-only"; +import requestLinkFormSchema from "../formSchemas/requestLinkFormSchema"; +import DashboardError from "@/modules/dashboard/errors/DashboardError"; +import mapObjectToFirstValue from "@/utils/mapObjectToFirstValue"; +import db from "@/core/db"; +import getCurrentUser from "@/modules/auth/utils/getCurrentUser"; +import { revalidatePath } from "next/cache"; + +export default async function createLinkRequest( + formData: RequestLinkForm +): Promise { + try { + if (!(await checkPermission("office-365-request.create"))) + unauthorized(); + + const currentUser = await getCurrentUser(); + if (!currentUser) return unauthorized(); + + const validatedFields = requestLinkFormSchema.safeParse(formData); + if (!validatedFields.success) { + throw new DashboardError({ + errorCode: "INVALID_FORM_DATA", + formErrors: mapObjectToFirstValue( + validatedFields.error.flatten().fieldErrors + ), + }); + } + + //database operations + await db.office365LinkRequest.create({ + data: { + creator: { + connect: { + id: currentUser.id, + }, + }, + status: "WAITING", + links: { + createMany: { + data: validatedFields.data.details.map((detail) => ({ + numberOfUsers: detail.endUserQty, + activePeriod: detail.activePeriod, + email: detail.email, + })), + }, + }, + }, + }); + + revalidatePath(".") + + return { + success: true, + message: + "Your request has been made. Please wait while our admin processing your request", + }; + } catch (e) { + return handleCatch(e); + } +} diff --git a/src/modules/resellerOffice365/actions/getLinkRequests.ts b/src/modules/resellerOffice365/actions/getLinkRequests.ts new file mode 100644 index 0000000..a5f2eeb --- /dev/null +++ b/src/modules/resellerOffice365/actions/getLinkRequests.ts @@ -0,0 +1,49 @@ +import ServerResponseAction from "@/modules/dashboard/types/ServerResponseAction"; +import RequestLink from "../types/RequestLink"; +import handleCatch from "@/modules/dashboard/utils/handleCatch"; +import checkPermission from "@/modules/dashboard/services/checkPermission"; +import unauthorized from "@/modules/dashboard/utils/unauthorized"; +import getCurrentUser from "@/modules/auth/utils/getCurrentUser"; +import db from "@/core/db"; + +export default async function getLinkRequests(): Promise< + ServerResponseAction +> { + try { + if (!(await checkPermission("office-365-request.getMine"))) + unauthorized(); + + const user = await getCurrentUser(); + + if (!user) return unauthorized(); + + const requests = await db.office365LinkRequest.findMany({ + where: { + creator: { id: user.id }, + }, + select: { + id: true, + requestedAt: true, + status: true, + links: true, + }, + }); + + const result: RequestLink[] = requests.map((request) => ({ + id: request.id, + requestDate: request.requestedAt.toISOString(), + status: request.status, + userCount: request.links.reduce( + (prev, curr) => prev + curr.numberOfUsers, + 0 + ), + })); + + return { + success: true, + data: result, + }; + } catch (e) { + return handleCatch(e); + } +} diff --git a/src/modules/resellerOffice365/formSchemas/requestLinkFormSchema.ts b/src/modules/resellerOffice365/formSchemas/requestLinkFormSchema.ts new file mode 100644 index 0000000..2b58b5d --- /dev/null +++ b/src/modules/resellerOffice365/formSchemas/requestLinkFormSchema.ts @@ -0,0 +1,15 @@ +import { z } from "zod"; +import resellerOffice365Config from "../config"; + +const requestLinkFormSchema = z.object({ + numberOfLinks: z.number().min(1), // Assuming you need at least one link + details: z.array( + z.object({ + email: z.string().email(), // Validate string as an email + activePeriod: z.enum(resellerOffice365Config.activePeriods), // Validate against the specific allowed values + endUserQty: z.number().min(1), // Assuming you need at least one end user + }) + ), +}); + +export default requestLinkFormSchema; diff --git a/src/modules/resellerOffice365/modals/RequestModal.tsx b/src/modules/resellerOffice365/modals/RequestModal.tsx index 73af93a..99060ad 100644 --- a/src/modules/resellerOffice365/modals/RequestModal.tsx +++ b/src/modules/resellerOffice365/modals/RequestModal.tsx @@ -1,4 +1,5 @@ import { + Alert, Button, Divider, Fieldset, @@ -10,8 +11,10 @@ import { Select, Stack, TextInput, + Loader, + Text, } from "@mantine/core"; -import { useForm } from "@mantine/form"; +import { useForm, zodResolver } from "@mantine/form"; import React, { useState } from "react"; import { TbAt, @@ -22,6 +25,11 @@ import { TbUsers, } from "react-icons/tb"; import resellerOffice365Config from "../config"; +import requestLinkFormSchema from "../formSchemas/requestLinkFormSchema"; +import withServerAction from "@/modules/dashboard/utils/withServerAction"; +import createLinkRequest from "../actions/createLinkRequest"; +import { notifications } from "@mantine/notifications"; +import DashboardError from "@/modules/dashboard/errors/DashboardError"; export interface ModalProps { title: string; @@ -30,25 +38,18 @@ export interface ModalProps { onClose?: () => void; } -interface FormType { - numberOfLinks: number; - details: { - email: string; - activePeriod: (typeof resellerOffice365Config.activePeriods)[number]; - endUserQty: number; - }[]; -} - export default function RequestModal(props: ModalProps) { const [formState, setFormState] = useState< "idle" | "submitting" | "waiting" >("idle"); + const [errorMessage, setErrorMessage] = useState(""); + const closeModal = () => { props.onClose ? props.onClose() : ""; }; - const form = useForm({ + const form = useForm({ initialValues: { numberOfLinks: 1, details: [ @@ -59,6 +60,7 @@ export default function RequestModal(props: ModalProps) { }, ], }, + validate: zodResolver(requestLinkFormSchema), onValuesChange: (values, prev) => { // Check if numberOfLinks has changed if (values.numberOfLinks !== prev.numberOfLinks) { @@ -90,24 +92,60 @@ export default function RequestModal(props: ModalProps) { const disableChange = formState !== "idle"; - const handleSubmit = (values: FormType) => { + const handleSubmit = (values: RequestLinkForm) => { const submitableState = ["idle"]; if (!submitableState.includes(formState)) return; //prevent submit setFormState("submitting"); + + withServerAction(createLinkRequest, values) + .then((response) => { + notifications.show({ + message: response.message, + color: "green", + }); + setFormState("waiting"); + }) + .catch((e) => { + if (e instanceof DashboardError) { + if (e.errorCode === "INVALID_FORM_DATA") { + if (e.formErrors) { + form.setErrors(e.formErrors); + } else { + setErrorMessage(e.message); + } + } else { + setErrorMessage(`ERROR: ${e.message} (${e.errorCode})`); + } + } else if (e instanceof Error) { + setErrorMessage(`ERROR: ${e.message}`); + } else { + setErrorMessage( + `Unkown error is occured. Please contact administrator` + ); + } + + setFormState("idle"); + }); }; return (
+ {formState === "waiting" && ( + + Your request is being processed by administrator + + )} + } type="submit" - loading={["submitting", "waiting"].includes( + disabled={["submitting", "waiting"].includes( formState )} + loading={["submitting"].includes(formState)} > Make Request diff --git a/src/modules/resellerOffice365/tables/RequestTable/columns.tsx b/src/modules/resellerOffice365/tables/RequestTable/columns.tsx index d75909c..44fe0d1 100644 --- a/src/modules/resellerOffice365/tables/RequestTable/columns.tsx +++ b/src/modules/resellerOffice365/tables/RequestTable/columns.tsx @@ -6,7 +6,7 @@ import createActionButtons from "@/modules/dashboard/utils/createActionButton"; export interface RequestLinkRow { id: string; - requestDate: Date, + requestDate: string, userCount: number, status: string } @@ -32,6 +32,10 @@ const createColumns = (options: ColumnOptions) => { columnHelper.accessor("requestDate", { header: "Request Date", + cell: (props) => { + const date = new Date(props.row.original.requestDate); + return `${date.toDateString()}; ${date.toLocaleTimeString()}` + } }), columnHelper.accessor("userCount", { diff --git a/src/modules/resellerOffice365/types/RequestLink.d.ts b/src/modules/resellerOffice365/types/RequestLink.d.ts index e56d7e0..3e60ee5 100644 --- a/src/modules/resellerOffice365/types/RequestLink.d.ts +++ b/src/modules/resellerOffice365/types/RequestLink.d.ts @@ -1,6 +1,6 @@ export default interface RequestLink { id: string; - requestDate: Date, + requestDate: string, userCount: number, status: string } \ No newline at end of file diff --git a/src/modules/resellerOffice365/types/RequestLinkForm.d.ts b/src/modules/resellerOffice365/types/RequestLinkForm.d.ts new file mode 100644 index 0000000..30d11de --- /dev/null +++ b/src/modules/resellerOffice365/types/RequestLinkForm.d.ts @@ -0,0 +1,8 @@ +interface RequestLinkForm { + numberOfLinks: number; + details: { + email: string; + activePeriod: (typeof resellerOffice365Config.activePeriods)[number]; + endUserQty: number; + }[]; +}