├── .gitignore ├── LICENSE ├── README.md └── xapplications ├── .eslintrc.json ├── .gitignore ├── README.md ├── components.json ├── next.config.mjs ├── package-lock.json ├── package.json ├── postcss.config.mjs ├── public ├── next.svg └── vercel.svg ├── src ├── actions │ ├── admin │ │ ├── add-question.ts │ │ ├── add-reviewer.ts │ │ ├── create-application.ts │ │ ├── delete-question.ts │ │ ├── edit-application.ts │ │ ├── get-application.ts │ │ └── move-question-position.ts │ ├── database-connection.ts │ ├── get-all-applications.ts │ ├── get-application.ts │ ├── reviewer │ │ ├── accept-application.ts │ │ ├── deny-application.ts │ │ ├── get-application-stats.ts │ │ ├── get-application.ts │ │ ├── get-reviewers.ts │ │ ├── get-submitted-discord.ts │ │ ├── get-submitted-list.ts │ │ ├── get-submitted.ts │ │ ├── get-top-reviewer.ts │ │ └── search-application.ts │ ├── submit-application.ts │ └── user-has-role.ts ├── app │ ├── admin │ │ ├── (components) │ │ │ └── add-reviewer.tsx │ │ ├── create │ │ │ ├── (components) │ │ │ │ └── application-form.tsx │ │ │ └── page.tsx │ │ ├── edit │ │ │ └── [applicationId] │ │ │ │ ├── (components) │ │ │ │ ├── add-question.tsx │ │ │ │ ├── edit-form.tsx │ │ │ │ ├── question-row.tsx │ │ │ │ └── question-table.tsx │ │ │ │ └── page.tsx │ │ └── page.tsx │ ├── api │ │ └── auth │ │ │ └── [...nextauth] │ │ │ └── route.ts │ ├── applications │ │ ├── [applicationId] │ │ │ ├── (components) │ │ │ │ └── application-form.tsx │ │ │ └── page.tsx │ │ └── page.tsx │ ├── favicon.ico │ ├── globals.css │ ├── layout.tsx │ ├── page.tsx │ └── reviewer │ │ ├── [applicationId] │ │ ├── (components) │ │ │ └── stats-chart.tsx │ │ ├── applications │ │ │ ├── (components) │ │ │ │ ├── actions.tsx │ │ │ │ └── search-application.tsx │ │ │ ├── [submittedId] │ │ │ │ ├── (components) │ │ │ │ │ └── actions.tsx │ │ │ │ └── page.tsx │ │ │ └── page.tsx │ │ └── page.tsx │ │ └── page.tsx ├── components │ ├── header.tsx │ ├── session-provider.tsx │ └── ui │ │ ├── alert-dialog.tsx │ │ ├── badge.tsx │ │ ├── button.tsx │ │ ├── card.tsx │ │ ├── chart.tsx │ │ ├── checkbox.tsx │ │ ├── dropdown-menu.tsx │ │ ├── fade-text.tsx │ │ ├── form.tsx │ │ ├── input.tsx │ │ ├── label.tsx │ │ ├── radial-gradient.tsx │ │ ├── select.tsx │ │ ├── sonner.tsx │ │ ├── table.tsx │ │ └── textarea.tsx ├── config │ └── config.ts ├── lib │ ├── auth.ts │ └── utils.ts └── schemas │ ├── applications.ts │ └── submitted.ts ├── tailwind.config.ts └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | 10 | fake.ts 11 | 12 | # Diagnostic reports (https://nodejs.org/api/report.html) 13 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 14 | 15 | # Runtime data 16 | pids 17 | *.pid 18 | *.seed 19 | *.pid.lock 20 | 21 | # Directory for instrumented libs generated by jscoverage/JSCover 22 | lib-cov 23 | 24 | # Coverage directory used by tools like istanbul 25 | coverage 26 | *.lcov 27 | 28 | # nyc test coverage 29 | .nyc_output 30 | 31 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 32 | .grunt 33 | 34 | # Bower dependency directory (https://bower.io/) 35 | bower_components 36 | 37 | # node-waf configuration 38 | .lock-wscript 39 | 40 | # Compiled binary addons (https://nodejs.org/api/addons.html) 41 | build/Release 42 | 43 | # Dependency directories 44 | node_modules/ 45 | jspm_packages/ 46 | 47 | # Snowpack dependency directory (https://snowpack.dev/) 48 | web_modules/ 49 | 50 | # TypeScript cache 51 | *.tsbuildinfo 52 | 53 | # Optional npm cache directory 54 | .npm 55 | 56 | # Optional eslint cache 57 | .eslintcache 58 | 59 | # Optional stylelint cache 60 | .stylelintcache 61 | 62 | # Microbundle cache 63 | .rpt2_cache/ 64 | .rts2_cache_cjs/ 65 | .rts2_cache_es/ 66 | .rts2_cache_umd/ 67 | 68 | # Optional REPL history 69 | .node_repl_history 70 | 71 | # Output of 'npm pack' 72 | *.tgz 73 | 74 | # Yarn Integrity file 75 | .yarn-integrity 76 | 77 | # dotenv environment variable files 78 | .env 79 | .env.development.local 80 | .env.test.local 81 | .env.production.local 82 | .env.local 83 | 84 | # parcel-bundler cache (https://parceljs.org/) 85 | .cache 86 | .parcel-cache 87 | 88 | # Next.js build output 89 | .next 90 | out 91 | 92 | # Nuxt.js build / generate output 93 | .nuxt 94 | dist 95 | 96 | # Gatsby files 97 | .cache/ 98 | # Comment in the public line in if your project uses Gatsby and not Next.js 99 | # https://nextjs.org/blog/next-9-1#public-directory-support 100 | # public 101 | 102 | # vuepress build output 103 | .vuepress/dist 104 | 105 | # vuepress v2.x temp and cache directory 106 | .temp 107 | .cache 108 | 109 | # Docusaurus cache and generated files 110 | .docusaurus 111 | 112 | # Serverless directories 113 | .serverless/ 114 | 115 | # FuseBox cache 116 | .fusebox/ 117 | 118 | # DynamoDB Local files 119 | .dynamodb/ 120 | 121 | # TernJS port file 122 | .tern-port 123 | 124 | # Stores VSCode versions used for testing VSCode extensions 125 | .vscode-test 126 | 127 | # yarn v2 128 | .yarn/cache 129 | .yarn/unplugged 130 | .yarn/build-state.yml 131 | .yarn/install-state.gz 132 | .pnp.* 133 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 HampuzX 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # XApplications 2 | A user-friendly web portal designed for managing applications in FiveM servers. Streamline your server's recruitment process with an intuitive interface, customizable forms, and efficient applicant tracking. 3 | 4 | ### Please consider giving a star to the project! 5 | 6 | ## Features 7 | - Admin Dashboard 8 | - Reviewer Dashboard 9 | - Application Page 10 | - Discord Synced 11 | 12 | ## Setup 13 | Since the project is built using NextJS it's recommended to host using Vercel, a tutorial on how to do that can be found at [hosting-your-first-website-on-vercel-a-step-by-step-guide](https://medium.com/@hikmohadetunji/hosting-your-first-website-on-vercel-a-step-by-step-guide-95061f1ca687). If you need further help join my discord server linked below. 14 | 15 | # Discord 16 | https://discord.gg/Q6ynnA8z 17 | -------------------------------------------------------------------------------- /xapplications/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals", 3 | "rules": { 4 | "react/no-unescaped-entities": "off" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /xapplications/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | -------------------------------------------------------------------------------- /xapplications/README.md: -------------------------------------------------------------------------------- 1 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). 2 | 3 | ## Getting Started 4 | 5 | First, run the development server: 6 | 7 | ```bash 8 | npm run dev 9 | # or 10 | yarn dev 11 | # or 12 | pnpm dev 13 | # or 14 | bun dev 15 | ``` 16 | 17 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 18 | 19 | You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. 20 | 21 | This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font. 22 | 23 | ## Learn More 24 | 25 | To learn more about Next.js, take a look at the following resources: 26 | 27 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 28 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 29 | 30 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! 31 | 32 | ## Deploy on Vercel 33 | 34 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 35 | 36 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. 37 | -------------------------------------------------------------------------------- /xapplications/components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "default", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "src/app/globals.css", 9 | "baseColor": "neutral", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils" 16 | } 17 | } -------------------------------------------------------------------------------- /xapplications/next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | images: { 4 | remotePatterns: [ 5 | { 6 | protocol: 'https', 7 | hostname: 'cdn.discordapp.com', 8 | port: '' 9 | } 10 | ] 11 | } 12 | }; 13 | 14 | export default nextConfig; 15 | -------------------------------------------------------------------------------- /xapplications/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "xapplications", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@discordjs/rest": "^2.3.0", 13 | "@hookform/resolvers": "^3.9.0", 14 | "@radix-ui/react-alert-dialog": "^1.1.1", 15 | "@radix-ui/react-checkbox": "^1.1.1", 16 | "@radix-ui/react-dropdown-menu": "^2.1.1", 17 | "@radix-ui/react-label": "^2.1.0", 18 | "@radix-ui/react-select": "^2.1.1", 19 | "@radix-ui/react-slot": "^1.1.0", 20 | "class-variance-authority": "^0.7.0", 21 | "clsx": "^2.1.1", 22 | "discord-api-types": "^0.37.92", 23 | "framer-motion": "^11.2.13", 24 | "lucide-react": "^0.400.0", 25 | "mongoose": "^8.4.4", 26 | "next": "14.2.4", 27 | "next-auth": "^4.24.7", 28 | "next-themes": "^0.3.0", 29 | "react": "^18", 30 | "react-dom": "^18", 31 | "react-hook-form": "^7.52.1", 32 | "recharts": "^2.12.7", 33 | "sonner": "^1.5.0", 34 | "tailwind-merge": "^2.3.0", 35 | "tailwindcss-animate": "^1.0.7", 36 | "zod": "^3.23.8" 37 | }, 38 | "devDependencies": { 39 | "@types/node": "^20", 40 | "@types/react": "^18", 41 | "@types/react-dom": "^18", 42 | "eslint": "^8", 43 | "eslint-config-next": "14.2.4", 44 | "postcss": "^8", 45 | "tailwindcss": "^3.4.1", 46 | "typescript": "^5" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /xapplications/postcss.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('postcss-load-config').Config} */ 2 | const config = { 3 | plugins: { 4 | tailwindcss: {}, 5 | }, 6 | }; 7 | 8 | export default config; 9 | -------------------------------------------------------------------------------- /xapplications/public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /xapplications/public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /xapplications/src/actions/admin/add-question.ts: -------------------------------------------------------------------------------- 1 | "use server" 2 | 3 | import { DEPARTMENT } from "@/config/config"; 4 | import { authOptions } from "@/lib/auth"; 5 | import { ExtentedUser } from "@/lib/utils"; 6 | import Applications, { QuestionTypes } from "@/schemas/applications"; 7 | import { getServerSession } from "next-auth"; 8 | import { createConnection } from "../database-connection"; 9 | 10 | export const addQuestionAction = async (id: string, question: string, type: QuestionTypes, required: boolean) => { 11 | const session = await getServerSession(authOptions) as ExtentedUser 12 | if(!session || !session.user || session.user.id !== DEPARTMENT.OWNER) return null; 13 | 14 | await createConnection(); 15 | 16 | const application = await Applications.findOne({ id }); 17 | if(!application) return null; 18 | 19 | application.questions?.push({ 20 | question, 21 | type, 22 | required, 23 | position: application.questions.length + 1 24 | }); 25 | await application.save(); 26 | 27 | return application.id; 28 | } -------------------------------------------------------------------------------- /xapplications/src/actions/admin/add-reviewer.ts: -------------------------------------------------------------------------------- 1 | "use server" 2 | 3 | import { DEPARTMENT } from "@/config/config"; 4 | import { authOptions } from "@/lib/auth"; 5 | import { ExtentedUser } from "@/lib/utils"; 6 | import Applications from "@/schemas/applications"; 7 | import { getServerSession } from "next-auth"; 8 | import { createConnection } from "../database-connection"; 9 | 10 | export const addReviewerAction = async (applicationId: string, userId: string) => { 11 | const session = await getServerSession(authOptions) as ExtentedUser 12 | if(!session || !session.user || session.user.id !== DEPARTMENT.OWNER) return null; 13 | 14 | await createConnection(); 15 | 16 | const application = await Applications.findOne({ id: applicationId }); 17 | if(!application) return null; 18 | 19 | application.allowedReviewers.push(userId); 20 | await application.save(); 21 | 22 | return application.id; 23 | } -------------------------------------------------------------------------------- /xapplications/src/actions/admin/create-application.ts: -------------------------------------------------------------------------------- 1 | 'use server' 2 | 3 | import { DEPARTMENT } from "@/config/config"; 4 | import { authOptions } from "@/lib/auth"; 5 | import { ExtentedUser } from "@/lib/utils"; 6 | import Applications, { IApplication } from "@/schemas/applications"; 7 | import { getServerSession } from "next-auth"; 8 | import { createConnection } from "../database-connection"; 9 | 10 | export const createApplicationAction = async (name: string, description: string, status: string, acceptedRole: string, notifyOnAccept: string, notifyOnDeny: string, neededRole?: string) => { 11 | const session = await getServerSession(authOptions) as ExtentedUser 12 | if(!session || !session.user || session.user.id === DEPARTMENT.OWNER) return null; 13 | 14 | const id = Math.random().toString(36).substring(7); 15 | 16 | await createConnection(); 17 | 18 | const newApplication = new Applications({ 19 | name, 20 | id, 21 | description, 22 | status: status === "Open" ? true : false, 23 | allowedReviewers: [session.user.id as string], 24 | acceptedRole, 25 | notifyOnAccept: notifyOnAccept === "Yes" ? true : false, 26 | notifyOnDeny: notifyOnDeny === "Yes" ? true : false, 27 | neededRole 28 | }); 29 | 30 | await newApplication.save(); 31 | 32 | return newApplication.id; 33 | }; -------------------------------------------------------------------------------- /xapplications/src/actions/admin/delete-question.ts: -------------------------------------------------------------------------------- 1 | 'use server' 2 | 3 | import { DEPARTMENT } from "@/config/config"; 4 | import { authOptions } from "@/lib/auth"; 5 | import { ExtentedUser } from "@/lib/utils"; 6 | import Applications from "@/schemas/applications"; 7 | import { getServerSession } from "next-auth"; 8 | import { createConnection } from "../database-connection"; 9 | 10 | export const deleteQuestionAction = async (applicationId: string, position: number) => { 11 | const session = await getServerSession(authOptions) as ExtentedUser; 12 | if(!session || !session.user || session.user.id !== DEPARTMENT.OWNER) return null; 13 | 14 | await createConnection(); 15 | 16 | const application = await Applications.findOne({ id: applicationId }); 17 | if(!application) return null; 18 | 19 | const question = await application.questions.find((q: { position: number; }) => q.position === position); 20 | if(!question) return null; 21 | 22 | application.questions = application.questions.filter((q: { position: number; }) => q.position !== position); 23 | application.questions.forEach((q: { position: number; }) => { 24 | if(q.position > position) q.position -= 1; 25 | }); 26 | 27 | await application.save(); 28 | 29 | return application.id; 30 | } -------------------------------------------------------------------------------- /xapplications/src/actions/admin/edit-application.ts: -------------------------------------------------------------------------------- 1 | 'use server' 2 | 3 | import { DEPARTMENT } from "@/config/config"; 4 | import { authOptions } from "@/lib/auth"; 5 | import { ExtentedUser } from "@/lib/utils"; 6 | import Applications from "@/schemas/applications"; 7 | import { getServerSession } from "next-auth"; 8 | import { createConnection } from "../database-connection"; 9 | 10 | export const editApplicationAction = async (id: string, name: string, description: string, status: string, acceptedRole: string, notifyOnAccept: string, notifyOnDeny: string, neededRole?: string) => { 11 | const session = await getServerSession(authOptions) as ExtentedUser 12 | if(!session || !session.user || session.user.id !== DEPARTMENT.OWNER) return null; 13 | 14 | await createConnection(); 15 | 16 | console.log(id); 17 | 18 | const application = await Applications.findOne({ id }); 19 | if(!application) return null; 20 | 21 | console.log(`${name} ${description} ${status} ${acceptedRole} ${notifyOnAccept} ${notifyOnDeny} ${neededRole}`) 22 | 23 | application.name = name; 24 | application.description = description; 25 | application.status = status === "Open" ? true : false; 26 | application.acceptedRole = acceptedRole; 27 | application.notifyOnAccept = notifyOnAccept === "Yes" ? true : false; 28 | application.notifyOnDeny = notifyOnDeny === "Yes" ? true : false; 29 | application.neededRole = neededRole; 30 | 31 | await application.save(); 32 | 33 | return application.id; 34 | } -------------------------------------------------------------------------------- /xapplications/src/actions/admin/get-application.ts: -------------------------------------------------------------------------------- 1 | "use server" 2 | 3 | import { DEPARTMENT } from "@/config/config"; 4 | import { authOptions } from "@/lib/auth"; 5 | import { ExtentedUser } from "@/lib/utils"; 6 | import Applications from "@/schemas/applications"; 7 | import { getServerSession } from "next-auth"; 8 | import { createConnection } from "../database-connection"; 9 | 10 | export const getApplicationAction = async (id: string) => { 11 | const session = await getServerSession(authOptions) as ExtentedUser; 12 | if(!session || !session.user || session.user.id !== DEPARTMENT.OWNER) return null; 13 | 14 | await createConnection(); 15 | 16 | const application = await Applications.findOne({ id }).lean(); 17 | if(!application) return null; 18 | 19 | return application; 20 | } -------------------------------------------------------------------------------- /xapplications/src/actions/admin/move-question-position.ts: -------------------------------------------------------------------------------- 1 | 'use server' 2 | 3 | import { DEPARTMENT } from "@/config/config"; 4 | import { authOptions } from "@/lib/auth"; 5 | import { ExtentedUser } from "@/lib/utils"; 6 | import Applications from "@/schemas/applications"; 7 | import { getServerSession } from "next-auth"; 8 | import { createConnection } from "../database-connection"; 9 | 10 | type PositionPath = "UP" | "DOWN"; 11 | 12 | export const moveQuestionPositionAction = async (applicationId: string, currentPosition: number, newPosition: number, path: PositionPath) => { 13 | const session = await getServerSession(authOptions) as ExtentedUser; 14 | if(!session || !session.user || session.user.id !== DEPARTMENT.OWNER) return null; 15 | 16 | await createConnection(); 17 | 18 | const application = await Applications.findOne({ id: applicationId }); 19 | if(!application) return null; 20 | 21 | const questionsLength = application.questions.length; 22 | if(path === "UP" && newPosition <= 0) return null; 23 | console.log(questionsLength, newPosition, currentPosition) 24 | if(path === "DOWN" && newPosition > questionsLength) return null; 25 | 26 | const question = await application.questions.find((q: { position: number; }) => q.position === newPosition); 27 | const currentQuestion = await application.questions.find((q: { position: number; }) => q.position === currentPosition); 28 | 29 | if(path === "UP") { 30 | question.position += 1; 31 | currentQuestion.position -= 1; 32 | 33 | await application.save(); 34 | } else { 35 | question.position -= 1; 36 | currentQuestion.position += 1; 37 | 38 | await application.save(); 39 | } 40 | 41 | return application.id; 42 | }; -------------------------------------------------------------------------------- /xapplications/src/actions/database-connection.ts: -------------------------------------------------------------------------------- 1 | import { DATABASE } from "@/config/config"; 2 | import { ConnectOptions, connect } from "mongoose"; 3 | 4 | const options: ConnectOptions = { 5 | useNewUrlParser: true, 6 | useUnifiedTopology: true, 7 | } as ConnectOptions; 8 | 9 | export const createConnection = async () => { 10 | await connect(DATABASE.URL, options) 11 | } -------------------------------------------------------------------------------- /xapplications/src/actions/get-all-applications.ts: -------------------------------------------------------------------------------- 1 | "use server" 2 | 3 | import { DEPARTMENT } from "@/config/config"; 4 | import { authOptions } from "@/lib/auth"; 5 | import { ExtentedUser } from "@/lib/utils"; 6 | import { getServerSession } from "next-auth"; 7 | import { createConnection } from "./database-connection"; 8 | import Applications from "@/schemas/applications"; 9 | 10 | export const getAllApplicationsAction = async () => { 11 | const session = await getServerSession(authOptions) as ExtentedUser; 12 | if(!session || !session.user || session.user.id !== DEPARTMENT.OWNER) return null; 13 | 14 | await createConnection(); 15 | 16 | const applications = await Applications.find(); 17 | 18 | return applications; 19 | } -------------------------------------------------------------------------------- /xapplications/src/actions/get-application.ts: -------------------------------------------------------------------------------- 1 | 'use server' 2 | 3 | import { authOptions } from "@/lib/auth"; 4 | import { ExtentedUser } from "@/lib/utils"; 5 | import Applications from "@/schemas/applications"; 6 | import { getServerSession } from "next-auth"; 7 | import { createConnection } from "./database-connection"; 8 | 9 | export const getApplicationAction = async (id: string) => { 10 | const session = await getServerSession(authOptions) as ExtentedUser; 11 | if(!session || !session.user || !session.user.id) return null; 12 | 13 | await createConnection(); 14 | 15 | const application = await Applications.findOne({ id }); 16 | if(!application) return null; 17 | 18 | const data = { 19 | id: application.id, 20 | name: application.name, 21 | description: application.description, 22 | status: application.status, 23 | notifyOnAccept: application.notifyOnAccept, 24 | notifyOnDeny: application.notifyOnDeny, 25 | questions: application.questions, 26 | neededRole: application.neededRole, 27 | } 28 | 29 | return data; 30 | } -------------------------------------------------------------------------------- /xapplications/src/actions/reviewer/accept-application.ts: -------------------------------------------------------------------------------- 1 | "use server" 2 | 3 | import { DEPARTMENT, DISCORD } from "@/config/config"; 4 | import { authOptions } from "@/lib/auth"; 5 | import { ExtentedUser } from "@/lib/utils"; 6 | import Applications from "@/schemas/applications"; 7 | import Submitted from "@/schemas/submitted"; 8 | import { REST } from "@discordjs/rest"; 9 | import { getServerSession } from "next-auth"; 10 | import { createConnection } from "../database-connection"; 11 | import { getReviewersAction } from "./get-reviewers"; 12 | 13 | export const acceptSubmittedAction = async (applicationId: string, submittedId: string, userId: string) => { 14 | const session = await getServerSession(authOptions) as ExtentedUser; 15 | const reviewers = await getReviewersAction(applicationId); 16 | if(!session || !session.user || session.user.id !== DEPARTMENT.OWNER || !reviewers || !reviewers.includes(session.user.id)) return null; 17 | 18 | await createConnection(); 19 | 20 | const rest = new REST({ version: '10' }).setToken(DISCORD.TOKEN); 21 | const application = await Applications.findOne({ id: applicationId }); 22 | if(!application) return null; 23 | 24 | const submitted = await Submitted.findOne({ applicationId, userId, submittedId }); 25 | if(!submitted) return null; 26 | 27 | try { 28 | rest.put(`/guilds/${DISCORD.GUILD_ID}/members/${userId}/roles/${application.acceptedRole}`, {}) 29 | } catch(e) { 30 | return null; 31 | } 32 | 33 | submitted.status = "ACCEPTED"; 34 | submitted.reviewerId = session.user.id; 35 | submitted.reviewedBy = session.user.name; 36 | 37 | await submitted.save(); 38 | 39 | return true; 40 | } -------------------------------------------------------------------------------- /xapplications/src/actions/reviewer/deny-application.ts: -------------------------------------------------------------------------------- 1 | "use server" 2 | 3 | import { DEPARTMENT } from "@/config/config"; 4 | import { authOptions } from "@/lib/auth"; 5 | import { ExtentedUser } from "@/lib/utils"; 6 | import Applications from "@/schemas/applications"; 7 | import Submitted from "@/schemas/submitted"; 8 | import { getServerSession } from "next-auth"; 9 | import { createConnection } from "../database-connection"; 10 | import { getReviewersAction } from "./get-reviewers"; 11 | 12 | export const denySubmittedAction = async (applicationId: string, submittedId: string, userId: string) => { 13 | const session = await getServerSession(authOptions) as ExtentedUser; 14 | const reviewers = await getReviewersAction(applicationId); 15 | if(!session || !session.user || session.user.id !== DEPARTMENT.OWNER || !reviewers || !reviewers.includes(session.user.id)) return null; 16 | 17 | await createConnection(); 18 | 19 | const application = await Applications.findOne({ id: applicationId }); 20 | if(!application) return null; 21 | 22 | const submitted = await Submitted.findOne({ applicationId, userId, submittedId }); 23 | if(!submitted) return null; 24 | 25 | submitted.status = "DENIED"; 26 | submitted.reviewerId = session.user.id; 27 | submitted.reviewedBy = session.user.name; 28 | 29 | await submitted.save(); 30 | 31 | return true; 32 | } -------------------------------------------------------------------------------- /xapplications/src/actions/reviewer/get-application-stats.ts: -------------------------------------------------------------------------------- 1 | "use server" 2 | 3 | import { DEPARTMENT } from "@/config/config"; 4 | import { authOptions } from "@/lib/auth"; 5 | import { ExtentedUser } from "@/lib/utils"; 6 | import Applications from "@/schemas/applications"; 7 | import Submitted, { ISubmitted } from "@/schemas/submitted"; 8 | import { getServerSession } from "next-auth"; 9 | import { createConnection } from "../database-connection"; 10 | import { getReviewersAction } from "./get-reviewers"; 11 | 12 | export const getApplicationStatsAction = async (applicationId: string) => { 13 | const session = await getServerSession(authOptions) as ExtentedUser; 14 | const reviewers = await getReviewersAction(applicationId); 15 | if(!session || !session.user || session.user.id !== DEPARTMENT.OWNER || !reviewers || !reviewers.includes(session.user.id)) return null; 16 | 17 | await createConnection(); 18 | 19 | const application = await Applications.findOne({ id: applicationId }); 20 | if(!application) return null; 21 | 22 | const submittedApplications = await Submitted.find({ applicationId }); 23 | 24 | const stats = { 25 | total: submittedApplications.length, 26 | reviewing: submittedApplications.filter((a: ISubmitted) => a.status === "REVIEWING").length, 27 | accepted: submittedApplications.filter((a: ISubmitted) => a.status === "ACCEPTED").length, 28 | rejected: submittedApplications.filter((a: ISubmitted) => a.status === "DENIED").length, 29 | } 30 | 31 | return stats; 32 | }; -------------------------------------------------------------------------------- /xapplications/src/actions/reviewer/get-application.ts: -------------------------------------------------------------------------------- 1 | "use server" 2 | 3 | import { DEPARTMENT } from "@/config/config"; 4 | import { authOptions } from "@/lib/auth"; 5 | import { ExtentedUser } from "@/lib/utils"; 6 | import Applications from "@/schemas/applications"; 7 | import { getServerSession } from "next-auth"; 8 | import { createConnection } from "../database-connection"; 9 | import { getReviewersAction } from "./get-reviewers"; 10 | 11 | export const getApplicationAction = async (id: string) => { 12 | const session = await getServerSession(authOptions) as ExtentedUser; 13 | const reviewers = await getReviewersAction(id); 14 | if(!session || !session.user || session.user.id !== DEPARTMENT.OWNER || !reviewers || !reviewers.includes(session.user.id)) return null; 15 | 16 | await createConnection(); 17 | 18 | const application = await Applications.findOne({ id }).lean(); 19 | if(!application) return null; 20 | 21 | return application; 22 | } -------------------------------------------------------------------------------- /xapplications/src/actions/reviewer/get-reviewers.ts: -------------------------------------------------------------------------------- 1 | 'use server' 2 | 3 | import { authOptions } from "@/lib/auth"; 4 | import { ExtentedUser } from "@/lib/utils"; 5 | import Applications, { IApplication } from "@/schemas/applications"; 6 | import { getServerSession } from "next-auth"; 7 | import { createConnection } from "../database-connection"; 8 | 9 | export const getReviewersAction = async (applicationId: string) => { 10 | const session = await getServerSession(authOptions) as ExtentedUser; 11 | if(!session) return null; 12 | 13 | await createConnection(); 14 | 15 | const application = await Applications.findOne({ id: applicationId }); 16 | if(!application) return null; 17 | 18 | return application.allowedReviewers; 19 | } -------------------------------------------------------------------------------- /xapplications/src/actions/reviewer/get-submitted-discord.ts: -------------------------------------------------------------------------------- 1 | "use server" 2 | 3 | import { DEPARTMENT, DISCORD } from "@/config/config"; 4 | import { authOptions } from "@/lib/auth"; 5 | import { ExtentedUser } from "@/lib/utils"; 6 | import Applications from "@/schemas/applications"; 7 | import Submitted from "@/schemas/submitted"; 8 | import { REST } from "@discordjs/rest"; 9 | import { Routes } from "discord-api-types/v10"; 10 | import { getServerSession } from "next-auth"; 11 | import { createConnection } from "../database-connection"; 12 | import { getReviewersAction } from "./get-reviewers"; 13 | 14 | export const getSubmittedDiscordAction = async (applicationId: string, submittedId: string, userId: string) => { 15 | const session = await getServerSession(authOptions) as ExtentedUser; 16 | const reviewers = await getReviewersAction(applicationId); 17 | if(!session || !session.user || session.user.id !== DEPARTMENT.OWNER || !reviewers || !reviewers.includes(session.user.id)) return null; 18 | 19 | await createConnection(); 20 | 21 | const rest = new REST({ version: '10' }).setToken(DISCORD.TOKEN); 22 | const application = await Applications.findOne({ id: applicationId }); 23 | if(!application) return null; 24 | 25 | const submitted = await Submitted.findOne({ applicationId, userId, submittedId }); 26 | if(!submitted) return null; 27 | 28 | try { 29 | const user = await rest.get(Routes.guildMember(DISCORD.GUILD_ID, userId)); 30 | return user; 31 | } catch(e) { 32 | return null; 33 | } 34 | } -------------------------------------------------------------------------------- /xapplications/src/actions/reviewer/get-submitted-list.ts: -------------------------------------------------------------------------------- 1 | "use server" 2 | 3 | import { DEPARTMENT } from "@/config/config"; 4 | import { authOptions } from "@/lib/auth"; 5 | import { ExtentedUser } from "@/lib/utils"; 6 | import Submitted from "@/schemas/submitted"; 7 | import { getServerSession } from "next-auth"; 8 | import { createConnection } from "../database-connection"; 9 | import { getReviewersAction } from "./get-reviewers"; 10 | 11 | export const getSubmittedListAction = async (id: string) => { 12 | const session = await getServerSession(authOptions) as ExtentedUser; 13 | const reviewers = await getReviewersAction(id); 14 | if(!session || !session.user || session.user.id !== DEPARTMENT.OWNER || !reviewers || !reviewers.includes(session.user.id)) return null; 15 | 16 | await createConnection(); 17 | 18 | const submittedApplications = await Submitted.find({ applicationId: id, status: "REVIEWING" }); 19 | 20 | return submittedApplications; 21 | } -------------------------------------------------------------------------------- /xapplications/src/actions/reviewer/get-submitted.ts: -------------------------------------------------------------------------------- 1 | "use server" 2 | 3 | import { DEPARTMENT } from "@/config/config"; 4 | import { authOptions } from "@/lib/auth"; 5 | import { ExtentedUser } from "@/lib/utils"; 6 | import Applications from "@/schemas/applications"; 7 | import Submitted from "@/schemas/submitted"; 8 | import { getServerSession } from "next-auth"; 9 | import { createConnection } from "../database-connection"; 10 | import { getReviewersAction } from "./get-reviewers"; 11 | 12 | export const getSubmittedAction = async (applicationId: string, submittedId: string) => { 13 | const session = await getServerSession(authOptions) as ExtentedUser; 14 | const reviewers = await getReviewersAction(applicationId); 15 | if(!session || !session.user || session.user.id !== DEPARTMENT.OWNER || !reviewers || !reviewers.includes(session.user.id)) return null; 16 | 17 | await createConnection(); 18 | 19 | const application = await Applications.findOne({ id: applicationId }); 20 | if(!application) return null; 21 | 22 | const submitted = await Submitted.findOne({ applicationId, submittedId }); 23 | if(!submitted) return null; 24 | 25 | return submitted; 26 | } -------------------------------------------------------------------------------- /xapplications/src/actions/reviewer/get-top-reviewer.ts: -------------------------------------------------------------------------------- 1 | 'use server' 2 | 3 | import { DEPARTMENT, DISCORD } from "@/config/config"; 4 | import { authOptions } from "@/lib/auth"; 5 | import { ExtentedUser } from "@/lib/utils"; 6 | import Applications from "@/schemas/applications"; 7 | import Submitted, { ISubmitted } from "@/schemas/submitted"; 8 | import { REST } from "@discordjs/rest"; 9 | import { Routes } from "discord-api-types/v10"; 10 | import { getServerSession } from "next-auth"; 11 | import { createConnection } from "../database-connection"; 12 | import { getReviewersAction } from "./get-reviewers"; 13 | 14 | export const getTopReviewerAction = async (applicationId: string) => { 15 | const session = await getServerSession(authOptions) as ExtentedUser; 16 | const reviewers = await getReviewersAction(applicationId); 17 | if(!session || !session.user || session.user.id !== DEPARTMENT.OWNER || !reviewers || !reviewers.includes(session.user.id)) return null; 18 | 19 | await createConnection(); 20 | 21 | const application = await Applications.findOne({ id: applicationId }); 22 | if(!application) return null; 23 | 24 | const submittedApplications = await Submitted.find({ applicationId }); 25 | 26 | const topReviewer = submittedApplications.reduce((acc: any, curr: ISubmitted) => { 27 | if(curr.reviewerId === "NONE") return acc; 28 | 29 | if(!acc[curr.reviewerId]) { 30 | acc[curr.reviewerId] = 1; 31 | } else { 32 | acc[curr.reviewerId]++; 33 | } 34 | return acc; 35 | }, {}); 36 | 37 | if(Object.keys(topReviewer).length === 0) return false; 38 | 39 | const rest = new REST({ version: '10' }).setToken(DISCORD.TOKEN); 40 | 41 | const user = await rest.get(Routes.guildMember(DISCORD.GUILD_ID, Object.keys(topReviewer).reduce((a: any, b: any) => topReviewer[a] > topReviewer[b] ? a : b))).catch((e) => { 42 | return false; 43 | }); 44 | 45 | return { 46 | name: (user as any).user.username, 47 | id: (user as any).user.id, 48 | reviews: topReviewer[(user as any).user.id], 49 | globalName: (user as any).user.global_name, 50 | nick: (user as any).user.nick, 51 | } 52 | }; -------------------------------------------------------------------------------- /xapplications/src/actions/reviewer/search-application.ts: -------------------------------------------------------------------------------- 1 | 'use server' 2 | 3 | import { DEPARTMENT } from "@/config/config"; 4 | import { authOptions } from "@/lib/auth"; 5 | import { ExtentedUser } from "@/lib/utils"; 6 | import Applications from "@/schemas/applications"; 7 | import Submitted from "@/schemas/submitted"; 8 | import { getServerSession } from "next-auth"; 9 | import { createConnection } from "../database-connection"; 10 | import { getReviewersAction } from "./get-reviewers"; 11 | 12 | export const searchApplicationAction = async (applicationId: string, query: string) => { 13 | const session = await getServerSession(authOptions) as ExtentedUser; 14 | const reviewers = await getReviewersAction(applicationId); 15 | if(!session || !session.user || session.user.id !== DEPARTMENT.OWNER || !reviewers || !reviewers.includes(session.user.id)) return null; 16 | 17 | await createConnection(); 18 | 19 | const application = await Applications.findOne({ id: applicationId }); 20 | if(!application) return null; 21 | 22 | // Find a submitted application with either userId or submittedId matching the query 23 | const submittedApplications = await Submitted.find({ applicationId, $or: [{ userId: query }, { submittedId: query }] }).lean(); 24 | if(!application) return null; 25 | 26 | return submittedApplications; 27 | } -------------------------------------------------------------------------------- /xapplications/src/actions/submit-application.ts: -------------------------------------------------------------------------------- 1 | 'use server' 2 | 3 | import { authOptions } from "@/lib/auth"; 4 | import { ExtentedUser } from "@/lib/utils"; 5 | import { getServerSession } from "next-auth"; 6 | import { createConnection } from "./database-connection"; 7 | import Applications, { IQuestion } from "@/schemas/applications"; 8 | import Submitted, { ISubmitted } from "@/schemas/submitted"; 9 | 10 | export const submitApplicationAction = async (applicationId: string, answers: any) => { 11 | const session = await getServerSession(authOptions) as ExtentedUser; 12 | if(!session || !session.user || !session.user.id) return null; 13 | 14 | await createConnection(); 15 | 16 | const application = await Applications.findOne({ id: applicationId }); 17 | if(!application) return null; 18 | 19 | const alreadySubmitted = await Submitted.findOne({ applicationId, userId: session.user.id, status: "REVIEWING" }); 20 | if(alreadySubmitted) return false; 21 | 22 | const applicationQuestions = application.questions; 23 | 24 | let convertedAnswers: { question: string, answer: string }[] = []; 25 | 26 | for(let i = 1; i < applicationQuestions.length + 1; i++) { 27 | const question = applicationQuestions.find((q: IQuestion) => q.position === i); 28 | if(question.required && !answers[i]) return false; 29 | 30 | convertedAnswers.push({ 31 | question: question.question, 32 | answer: answers[i] || "" 33 | }); 34 | } 35 | 36 | const randomId = Math.random().toString(36).substring(7); 37 | 38 | const createSubmitted = new Submitted({ 39 | applicationId: applicationId, 40 | userId: session.user.id, 41 | submittedId: randomId, 42 | answers: convertedAnswers, 43 | status: "REVIEWING", 44 | reviewerId: "NONE", 45 | reviewedBy: "NONE", 46 | createdAt: new Date(), 47 | }); 48 | 49 | await createSubmitted.save(); 50 | 51 | return true; 52 | }; -------------------------------------------------------------------------------- /xapplications/src/actions/user-has-role.ts: -------------------------------------------------------------------------------- 1 | 'use server' 2 | 3 | import { DISCORD } from "@/config/config"; 4 | import { authOptions } from "@/lib/auth"; 5 | import { ExtentedUser } from "@/lib/utils"; 6 | import { REST } from '@discordjs/rest'; 7 | import { Routes } from 'discord-api-types/v10'; 8 | import { getServerSession } from "next-auth"; 9 | 10 | export const userHasRoleAction = async (userId: string, expectedRole: string) => { 11 | const session = await getServerSession(authOptions) as ExtentedUser; 12 | if(!session || !session.user || !session.user.id) return null; 13 | 14 | if(session.user.id !== userId) return null; 15 | 16 | const rest = new REST({ version: '10' }).setToken(DISCORD.TOKEN); 17 | 18 | const guildId = DISCORD.GUILD_ID; 19 | 20 | try { 21 | const res: any = await rest.get(Routes.guildMember(guildId, userId)); 22 | return res.roles.includes(expectedRole); 23 | } catch (e) { 24 | console.error(e); 25 | return false; 26 | } 27 | }; -------------------------------------------------------------------------------- /xapplications/src/app/admin/(components)/add-reviewer.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { addReviewerAction } from "@/actions/admin/add-reviewer"; 4 | import { 5 | AlertDialog, 6 | AlertDialogCancel, 7 | AlertDialogContent, 8 | AlertDialogTrigger, 9 | } from "@/components/ui/alert-dialog"; 10 | import { Button } from "@/components/ui/button"; 11 | import { Input } from "@/components/ui/input"; 12 | import { UserPlus } from "lucide-react"; 13 | import { useState } from "react"; 14 | import { toast } from "sonner"; 15 | 16 | export const AddReviewer = ({ applicationId }: { applicationId: string }) => { 17 | const [reviewer, setReviewer] = useState(""); 18 | 19 | const handleAddReviewer = async () => { 20 | const add = await addReviewerAction(applicationId, reviewer); 21 | 22 | if(!add) return toast.error("Failed to add reviewer to the application."); 23 | } 24 | 25 | return ( 26 | 27 | 28 | 31 | 32 | 33 |
34 | 35 |
36 |

Add Reviewer

37 |

38 | They will be able to review applications and manage the 39 | application process. 40 |

41 |
42 |
43 |
44 |

Discord ID:

45 | setReviewer(e.target.value)} /> 46 |
47 | 48 | 51 | 52 | 53 | 56 | 57 |
58 |
59 | ); 60 | }; 61 | -------------------------------------------------------------------------------- /xapplications/src/app/admin/create/(components)/application-form.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { createApplicationAction } from "@/actions/admin/create-application"; 4 | import { Button } from "@/components/ui/button"; 5 | import { 6 | Form, 7 | FormControl, 8 | FormDescription, 9 | FormField, 10 | FormItem, 11 | FormLabel, 12 | FormMessage, 13 | } from "@/components/ui/form"; 14 | import { Input } from "@/components/ui/input"; 15 | import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; 16 | import { zodResolver } from "@hookform/resolvers/zod"; 17 | import { useForm } from "react-hook-form"; 18 | import { toast } from "sonner"; 19 | import { z } from "zod"; 20 | 21 | const applicationSchema = z.object({ 22 | name: z 23 | .string() 24 | .min(3, { message: "Name must be at least 3 characters." }) 25 | .max(50, { message: "Name must be at most 50 characters." }), 26 | description: z 27 | .string() 28 | .min(50, { message: "Description must be at least 50 characters." }) 29 | .max(250, { message: "Description must be at most 250 characters." }), 30 | status: z.literal("Open").or(z.literal("Closed")), 31 | acceptedRole: z.string().length(19), 32 | notifyOnAccept: z.literal("Yes").or(z.literal("No")), 33 | notifyOnDeny: z.literal("Yes").or(z.literal("No")), 34 | neededRole: z.string().length(18).optional() 35 | }); 36 | 37 | export const ApplicationForm = () => { 38 | const form = useForm>({ 39 | resolver: zodResolver(applicationSchema), 40 | defaultValues: { 41 | name: "", 42 | description: "", 43 | status: "Open", 44 | acceptedRole: "", 45 | notifyOnAccept: "Yes", 46 | notifyOnDeny: "Yes", 47 | }, 48 | }); 49 | 50 | async function onSubmit(values: z.infer) { 51 | const submit = await createApplicationAction( 52 | values.name, 53 | values.description, 54 | values.status, 55 | values.acceptedRole, 56 | values.notifyOnAccept, 57 | values.notifyOnDeny, 58 | values.neededRole 59 | ) 60 | 61 | if(!submit) return toast.error("Failed to create application.") 62 | 63 | toast.success("Application created successfully. Redirecting...") 64 | 65 | window.location.href = `/admin` 66 | } 67 | 68 | return ( 69 |
70 | 71 |
72 | ( 76 | 77 | Name 78 | 79 | 80 | 81 | 82 | This is the name of the application. 83 | 84 | 85 | 86 | )} 87 | /> 88 | ( 92 | 93 | Description 94 | 95 | 96 | 97 | 98 | Short description of the application. 99 | 100 | 101 | 102 | )} 103 | /> 104 | ( 108 | 109 | Status 110 | 111 | 120 | 121 | 122 | This is the status of the application. 123 | 124 | 125 | 126 | )} 127 | /> 128 | ( 132 | 133 | Accepted Role 134 | 135 | 136 | 137 | 138 | Will be assigned to the user when accepted. 139 | 140 | 141 | 142 | )} 143 | /> 144 | ( 148 | 149 | Notify on Accepted 150 | 151 | 160 | 161 | 162 | Notify the user when accepted. 163 | 164 | 165 | 166 | )} 167 | /> 168 | ( 172 | 173 | Notify on Denied 174 | 175 | 184 | 185 | 186 | Notify the user when denied. 187 | 188 | 189 | 190 | )} 191 | /> 192 | ( 196 | 197 | Needed Role (optional) 198 | 199 | 200 | 201 | 202 | Role needed to submit the application. 203 | 204 | 205 | 206 | )} 207 | /> 208 |
209 | 210 |
211 | 212 | ); 213 | }; 214 | -------------------------------------------------------------------------------- /xapplications/src/app/admin/create/page.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Card, 3 | CardContent, 4 | CardDescription, 5 | CardHeader, 6 | CardTitle 7 | } from "@/components/ui/card"; 8 | import { DEPARTMENT } from "@/config/config"; 9 | import { authOptions } from "@/lib/auth"; 10 | import { ExtentedUser } from "@/lib/utils"; 11 | import { getServerSession } from "next-auth"; 12 | import { redirect } from "next/navigation"; 13 | import { ApplicationForm } from "./(components)/application-form"; 14 | 15 | export default async function AdminPage() { 16 | const session = await getServerSession(authOptions) as ExtentedUser; 17 | if(!session || !session.user) return redirect("/"); 18 | 19 | if(session.user.id !== DEPARTMENT.OWNER) return redirect("/"); 20 | 21 | return ( 22 |
23 |
24 |
25 |
26 | Admin Dashboard 27 |
28 | 33 |
34 |
35 | 36 | 37 |
38 | 39 | New Application 40 | 41 | 42 | Create a new application that users can apply for. 43 | 44 |
45 |
46 | 47 | 48 | 49 |
50 |
51 |
52 |
53 | ); 54 | } -------------------------------------------------------------------------------- /xapplications/src/app/admin/edit/[applicationId]/(components)/add-question.tsx: -------------------------------------------------------------------------------- 1 | import { addQuestionAction } from "@/actions/admin/add-question"; 2 | import { 3 | AlertDialog, 4 | AlertDialogCancel, 5 | AlertDialogContent, 6 | AlertDialogTrigger, 7 | } from "@/components/ui/alert-dialog"; 8 | import { Button } from "@/components/ui/button"; 9 | import { Input } from "@/components/ui/input"; 10 | import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; 11 | import { IQuestion, QuestionTypes } from "@/schemas/applications"; 12 | import { Quote } from "lucide-react"; 13 | import { Dispatch, SetStateAction, useState } from "react"; 14 | 15 | interface AddProps { 16 | applicationId: string; 17 | setQuestions: Dispatch>; 18 | } 19 | 20 | export const AddQuestion = ({ applicationId, setQuestions }: AddProps) => { 21 | const [question, setQuestion] = useState(""); 22 | const [type, setType] = useState("SHORT"); 23 | const [required, setRequired] = useState("Yes"); 24 | 25 | const addQuestion = async () => { 26 | setQuestions((prev: IQuestion[]) => [ 27 | ...prev, 28 | { 29 | question, 30 | type, 31 | required: required === "Yes" ? true : false, 32 | position: prev.length + 1, 33 | }, 34 | ]); 35 | 36 | const convertedRequired = required === "Yes" ? true : false; 37 | 38 | const add = await addQuestionAction(applicationId, question, type, convertedRequired); 39 | console.log(add); 40 | if (add) { 41 | setQuestion(""); 42 | setType("SHORT"); 43 | setRequired("Yes"); 44 | } 45 | 46 | return; 47 | } 48 | 49 | return ( 50 | 51 | 52 | 55 | 56 | 57 |
58 | 59 |
60 |

Add Question

61 |

62 | Add a question to the application form for applicants to answer. 63 |

64 |
65 |
66 |
67 |

Question

68 | setQuestion(e.target.value)} /> 69 |
70 |
71 |

Type

72 | 82 |
83 |
84 |

Required

85 | 94 |
95 | 96 | 99 | 100 | 101 | 104 | 105 |
106 |
107 | ) 108 | } -------------------------------------------------------------------------------- /xapplications/src/app/admin/edit/[applicationId]/(components)/edit-form.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { editApplicationAction } from "@/actions/admin/edit-application"; 4 | import { Button } from "@/components/ui/button"; 5 | import { 6 | Form, 7 | FormControl, 8 | FormDescription, 9 | FormField, 10 | FormItem, 11 | FormLabel, 12 | FormMessage, 13 | } from "@/components/ui/form"; 14 | import { Input } from "@/components/ui/input"; 15 | import { 16 | Select, 17 | SelectContent, 18 | SelectItem, 19 | SelectTrigger, 20 | SelectValue, 21 | } from "@/components/ui/select"; 22 | import { zodResolver } from "@hookform/resolvers/zod"; 23 | import { useForm } from "react-hook-form"; 24 | import { toast } from "sonner"; 25 | import { z } from "zod"; 26 | 27 | interface EditFormProps { 28 | applicationId: string; 29 | name: string; 30 | description: string; 31 | status: boolean; 32 | acceptedRole: string; 33 | notifyOnAccept: boolean; 34 | notifyOnDeny: boolean; 35 | neededRole?: string; 36 | } 37 | 38 | const applicationSchema = z.object({ 39 | name: z 40 | .string() 41 | .min(3, { message: "Name must be at least 3 characters." }) 42 | .max(50, { message: "Name must be at most 50 characters." }), 43 | description: z 44 | .string() 45 | .min(50, { message: "Description must be at least 50 characters." }) 46 | .max(250, { message: "Description must be at most 250 characters." }), 47 | status: z.literal("Open").or(z.literal("Closed")), 48 | acceptedRole: z.string().length(19), 49 | notifyOnAccept: z.literal("Yes").or(z.literal("No")), 50 | notifyOnDeny: z.literal("Yes").or(z.literal("No")), 51 | neededRole: z.string().length(19).optional().or(z.literal("")), 52 | }); 53 | 54 | export const EditForm = (application: EditFormProps) => { 55 | const form = useForm>({ 56 | resolver: zodResolver(applicationSchema), 57 | defaultValues: { 58 | name: application.name, 59 | description: application.description, 60 | status: application.status ? "Open" : "Closed", 61 | acceptedRole: application.acceptedRole, 62 | notifyOnAccept: application.notifyOnAccept ? "Yes" : "No", 63 | notifyOnDeny: application.notifyOnDeny ? "Yes" : "No", 64 | neededRole: application.neededRole || "", 65 | }, 66 | }); 67 | 68 | async function onSubmit(values: z.infer) { 69 | console.log(values); 70 | 71 | const submit = await editApplicationAction( 72 | application.applicationId, 73 | values.name, 74 | values.description, 75 | values.status, 76 | values.acceptedRole, 77 | values.notifyOnAccept, 78 | values.notifyOnDeny, 79 | values.neededRole || "" 80 | ); 81 | 82 | if (!submit) return toast.error("Failed to edit application."); 83 | 84 | toast.success("Application has been edited successfully."); 85 | } 86 | 87 | return ( 88 |
89 | 90 |
91 | ( 95 | 96 | Name 97 | 98 | 99 | 100 | 101 | This is the name of the application. 102 | 103 | 104 | 105 | )} 106 | /> 107 | ( 111 | 112 | Description 113 | 114 | 115 | 116 | 117 | Short description of the application. 118 | 119 | 120 | 121 | )} 122 | /> 123 | ( 127 | 128 | Status 129 | 145 | 146 | This is the status of the application. 147 | 148 | 149 | 150 | )} 151 | /> 152 | ( 156 | 157 | Accepted Role 158 | 159 | 160 | 161 | 162 | Will be assigned to the user when accepted. 163 | 164 | 165 | 166 | )} 167 | /> 168 | ( 172 | 173 | Notify on Accepted 174 | 190 | 191 | Notify the applicant when accepted. 192 | 193 | 194 | 195 | )} 196 | /> 197 | ( 201 | 202 | Notify on Denied 203 | 219 | 220 | Notify the applicant when denied. 221 | 222 | 223 | 224 | )} 225 | /> 226 | ( 230 | 231 | Needed Role (optional) 232 | 233 | 234 | 235 | 236 | Role needed to submit the application. 237 | 238 | 239 | 240 | )} 241 | /> 242 |
243 |
244 | 247 |
248 |
249 | 250 | ); 251 | }; 252 | -------------------------------------------------------------------------------- /xapplications/src/app/admin/edit/[applicationId]/(components)/question-row.tsx: -------------------------------------------------------------------------------- 1 | import { deleteQuestionAction } from "@/actions/admin/delete-question"; 2 | import { moveQuestionPositionAction } from "@/actions/admin/move-question-position"; 3 | import { Badge } from "@/components/ui/badge"; 4 | import { Button } from "@/components/ui/button"; 5 | import { 6 | DropdownMenu, 7 | DropdownMenuContent, 8 | DropdownMenuItem, 9 | DropdownMenuTrigger, 10 | } from "@/components/ui/dropdown-menu"; 11 | import { TableCell, TableRow } from "@/components/ui/table"; 12 | import { IQuestion } from "@/schemas/applications"; 13 | import { MoveHorizontalIcon } from "lucide-react"; 14 | import { Dispatch, SetStateAction } from "react"; 15 | import { toast } from "sonner"; 16 | 17 | interface QuestionProps { 18 | applicationId: string; 19 | position: number; 20 | question: string; 21 | type: string; 22 | required: boolean; 23 | setQuestions: Dispatch>; 24 | } 25 | 26 | export const QuestionRow = ({ applicationId, position, question, type, required, setQuestions }: QuestionProps) => { 27 | const moveUpClick = async () => { 28 | setQuestions((prev) => { 29 | const index = prev.findIndex((q) => q.position === position); 30 | if (index === 0) return prev; 31 | 32 | const newPosition = prev[index - 1].position; 33 | prev[index].position = newPosition; 34 | prev[index - 1].position = position; 35 | 36 | return [...prev]; 37 | }); 38 | 39 | const action = await moveQuestionPositionAction(applicationId, position, position - 1, "UP"); 40 | console.log(action); 41 | 42 | return toast.success("Question moved up successfully"); 43 | } 44 | 45 | const moveDownClick = async () => { 46 | setQuestions((prev) => { 47 | const index = prev.findIndex((q) => q.position === position); 48 | if (index === prev.length - 1) return prev; 49 | 50 | const newPosition = prev[index + 1].position; 51 | prev[index].position = newPosition; 52 | prev[index + 1].position = position; 53 | 54 | return [...prev]; 55 | }); 56 | 57 | const action = await moveQuestionPositionAction(applicationId, position, position + 1, "DOWN"); 58 | console.log(action); 59 | 60 | return toast.success("Question moved down successfully"); 61 | } 62 | 63 | const deleteClick = async () => { 64 | setQuestions((prev) => { 65 | const index = prev.findIndex((q) => q.position === position); 66 | prev.splice(index, 1); 67 | 68 | // Reorder the questions with the new position 69 | prev.forEach((q, i) => { 70 | q.position = i + 1; 71 | }); 72 | 73 | return [...prev]; 74 | }); 75 | 76 | const action = await deleteQuestionAction(applicationId, position); 77 | console.log(action); 78 | 79 | return toast.success("Question deleted successfully"); 80 | } 81 | 82 | 83 | return ( 84 | 85 | #{position} 86 | {question.slice(0, 50)} 87 | 88 | 89 | {type} 90 | 91 | 92 | 93 | {required ? "Yes" : "No"} 94 | 95 | 96 | 97 | 98 | 102 | 103 | 104 | Move Up 105 | Move Down 106 | Delete 107 | 108 | 109 | 110 | 111 | ); 112 | }; 113 | -------------------------------------------------------------------------------- /xapplications/src/app/admin/edit/[applicationId]/(components)/question-table.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { IQuestion } from "@/schemas/applications"; 4 | import { Badge } from "@/components/ui/badge"; 5 | import { Button } from "@/components/ui/button"; 6 | import { 7 | DropdownMenu, 8 | DropdownMenuContent, 9 | DropdownMenuItem, 10 | DropdownMenuTrigger, 11 | } from "@/components/ui/dropdown-menu"; 12 | import { 13 | Table, 14 | TableBody, 15 | TableCell, 16 | TableHead, 17 | TableHeader, 18 | TableRow, 19 | } from "@/components/ui/table"; 20 | import { MoveHorizontalIcon } from "lucide-react"; 21 | import { QuestionRow } from "./question-row"; 22 | import { useEffect, useState } from "react"; 23 | import { AddQuestion } from "./add-question"; 24 | 25 | interface QuestionProps { 26 | question: string; 27 | type: string; 28 | required: boolean; 29 | } 30 | 31 | export const QuestionTable = ({ applicationId, appQuestions }: any) => { 32 | const [questions, setQuestions] = useState(appQuestions || []); 33 | 34 | return ( 35 | <> 36 | 37 | 38 | 39 | Position 40 | Question 41 | Type 42 | Required 43 | Actions 44 | 45 | 46 | 47 | {questions && 48 | questions 49 | .sort((a, b) => a.position - b.position) 50 | .map((question: any, index: any) => ( 51 | 60 | ))} 61 | 62 |
63 | 64 | 65 | ); 66 | }; 67 | -------------------------------------------------------------------------------- /xapplications/src/app/admin/edit/[applicationId]/page.tsx: -------------------------------------------------------------------------------- 1 | import { getApplicationAction } from "@/actions/admin/get-application"; 2 | import { 3 | Card, 4 | CardContent, 5 | CardDescription, 6 | CardHeader, 7 | CardTitle 8 | } from "@/components/ui/card"; 9 | import { DEPARTMENT } from "@/config/config"; 10 | import { authOptions } from "@/lib/auth"; 11 | import { ExtentedUser } from "@/lib/utils"; 12 | import { IApplication } from "@/schemas/applications"; 13 | import { getServerSession } from "next-auth"; 14 | import { redirect } from "next/navigation"; 15 | import { EditForm } from "./(components)/edit-form"; 16 | import { QuestionTable } from "./(components)/question-table"; 17 | 18 | export default async function AdminPage({ 19 | params, 20 | }: { 21 | params: { applicationId: string }; 22 | }) { 23 | const session = (await getServerSession(authOptions)) as ExtentedUser; 24 | if (!session || !session.user) return redirect("/"); 25 | 26 | if (session.user.id !== DEPARTMENT.OWNER) return redirect("/"); 27 | 28 | const application = (await getApplicationAction( 29 | params.applicationId 30 | )) as unknown as IApplication; 31 | if (!application) return redirect("/admin"); 32 | 33 | return ( 34 |
35 | {/* */} 74 |
75 |
76 | 81 | 86 |
87 | {/*
88 | 89 | 90 | 91 | 92 | 93 |

1

94 |

Pending Applications

95 |
96 |
97 | 98 | 99 | 100 | 101 | 102 |

294

103 |

Approved Applications

104 |
105 |
106 | 107 | 108 | 109 | 110 | 111 |

121

112 |

Rejected Applications

113 |
114 |
115 | 116 | 117 | 118 | 119 | 120 |

1020

121 |

122 | Total Applications 123 |

124 |
125 |
126 |
*/} 127 |
128 | 129 | 130 |
131 | 132 | Edit Application - #{application.id.toUpperCase()} 133 | 134 | 135 | Edit the application and manage the application process. 136 | 137 |
138 |
139 | 140 | 150 | 151 |
152 | 153 | 154 |
155 | Application Questions 156 | 157 | Manage the questions for the application. 158 | 159 |
160 |
161 | 162 |
163 | 164 |
165 |
166 |
167 |
168 |
169 |
170 | ); 171 | } -------------------------------------------------------------------------------- /xapplications/src/app/admin/page.tsx: -------------------------------------------------------------------------------- 1 | import { getAllApplicationsAction } from "@/actions/get-all-applications"; 2 | import { Button } from "@/components/ui/button"; 3 | import { 4 | Card, 5 | CardContent, 6 | CardDescription, 7 | CardFooter, 8 | CardHeader, 9 | CardTitle, 10 | } from "@/components/ui/card"; 11 | import { DEPARTMENT } from "@/config/config"; 12 | import { authOptions } from "@/lib/auth"; 13 | import { ExtentedUser } from "@/lib/utils"; 14 | import { getServerSession } from "next-auth"; 15 | import { redirect } from "next/navigation"; 16 | import { AddReviewer } from "./(components)/add-reviewer"; 17 | 18 | export default async function AdminPage() { 19 | const session = (await getServerSession(authOptions)) as ExtentedUser; 20 | if (!session || !session.user) return redirect("/"); 21 | 22 | if (session.user.id !== DEPARTMENT.OWNER) return redirect("/"); 23 | 24 | const applications = (await getAllApplicationsAction()) || []; 25 | 26 | return ( 27 |
28 |
29 |
30 | 35 | 40 |
41 |
42 | 43 | 44 |
45 | 46 | Department Applications 47 | 48 | 49 | View and manage all department applications. 50 | 51 |
52 | 53 | 54 | 55 |
56 | 57 | {applications.map((application) => ( 58 | 59 | 60 | 61 | {application.name} 62 | 63 | 64 | {application.description.slice(0, 70) + "..."} 65 | 66 | 67 | 68 | 69 | 75 | 76 | 77 | 78 | 79 | ))} 80 | 81 |
82 |
83 |
84 |
85 | ); 86 | } -------------------------------------------------------------------------------- /xapplications/src/app/api/auth/[...nextauth]/route.ts: -------------------------------------------------------------------------------- 1 | import { authOptions } from "@/lib/auth"; 2 | import NextAuth from "next-auth"; 3 | 4 | const handler = NextAuth(authOptions) 5 | 6 | export { handler as GET, handler as POST }; 7 | -------------------------------------------------------------------------------- /xapplications/src/app/applications/[applicationId]/(components)/application-form.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { submitApplicationAction } from "@/actions/submit-application"; 4 | import { Button } from "@/components/ui/button"; 5 | import { Input } from "@/components/ui/input"; 6 | import { Label } from "@/components/ui/label"; 7 | import { Textarea } from "@/components/ui/textarea"; 8 | import { IQuestion } from "@/schemas/applications"; 9 | import { useState } from "react"; 10 | import { toast } from "sonner"; 11 | 12 | interface ApplicationFormProps { 13 | applicationId: string; 14 | serializedData: string; 15 | } 16 | 17 | export const ApplicationForm = ({ 18 | applicationId, 19 | serializedData, 20 | }: ApplicationFormProps) => { 21 | const [answers, setAnswers] = useState<{ [key: string]: string }>({}); 22 | const questions = JSON.parse(serializedData) as IQuestion[]; 23 | 24 | const handleSubmit = async () => { 25 | const submit = await submitApplicationAction(applicationId, answers); 26 | console.log(submit); 27 | if(submit) { 28 | window.location.href = "/applications"; 29 | } else { 30 | toast.error("Not all required fields are filled out or you have already submitted this application."); 31 | } 32 | }; 33 | 34 | return ( 35 |
36 | {questions 37 | .sort((a, b) => a.position - b.position) 38 | .map((question) => ( 39 |
40 | {question.type === "SHORT" && ( 41 | <> 42 | 43 | 48 | setAnswers((prev) => ({ 49 | ...prev, 50 | [question.position]: e.target.value, 51 | })) 52 | } 53 | /> 54 | 55 | )} 56 | {question.type === "LONG" && ( 57 | <> 58 | 59 |