diff --git a/api/src/routes/applications.ts b/api/src/routes/applications.ts index 76c0eca..c79514e 100644 --- a/api/src/routes/applications.ts +++ b/api/src/routes/applications.ts @@ -2,11 +2,13 @@ const express = require('express'); const router = express.Router(); import pool from '../db'; -import { approveApplication, createApplication, getApplicationByID, getApplicationComments, getApplicationList, getMemberApplication } from '../services/applicationService'; +import { approveApplication, createApplication, denyApplication, getAllMemberApplications, getApplicationByID, getApplicationComments, getApplicationList, getMemberApplication } from '../services/applicationService'; import { MemberState, setUserState } from '../services/memberService'; import { getRankByName, insertMemberRank } from '../services/rankService'; import { ApplicationFull, CommentRow } from "@app/shared/types/application" import { assignUserToStatus } from '../services/statusService'; +import { Request, Response } from 'express'; +import { getUserRoles } from '../services/rolesService'; // POST /application router.post('/', async (req, res) => { @@ -16,13 +18,13 @@ router.post('/', async (req, res) => { const appVersion = 1; - createApplication(memberID, appVersion, JSON.stringify(App)) - setUserState(memberID, MemberState.Applicant); + await createApplication(memberID, appVersion, JSON.stringify(App)) + await setUserState(memberID, MemberState.Applicant); res.sendStatus(201); } catch (err) { - console.error('Insert failed:', err); - res.status(500).json({ error: 'Failed to save application' }); + console.error('Failed to create application: \n', err); + res.status(500).json({ error: 'Failed to create application' }); } }); @@ -37,6 +39,20 @@ router.get('/all', async (req, res) => { } }); +router.get('/meList', async (req, res) => { + + let userID = req.user.id; + + try { + let application = await getAllMemberApplications(userID); + + return res.status(200).json(application); + } catch (error) { + console.error('Failed to load applications: \n', error); + return res.status(500).json(error); + } +}) + router.get('/me', async (req, res) => { let userID = req.user.id; @@ -62,15 +78,55 @@ router.get('/me', async (req, res) => { }) // GET /application/:id -router.get('/:id', async (req, res) => { - let appID = req.params.id; - console.log("HELLO") +router.get('/me/:id', async (req: Request, res: Response) => { + let appID = Number(req.params.id); + let member = req.user.id; + try { + const application = await getApplicationByID(appID); + if (application === undefined) + return res.sendStatus(204); + console.log(application.member_id, member) + if (application.member_id != member) { + return res.sendStatus(403); + } + + const comments: CommentRow[] = await getApplicationComments(appID); + + const output: ApplicationFull = { + application, + comments, + } + return res.status(200).json(output); + } + catch (err) { + console.error('Query failed:', err); + return res.status(500).json({ error: 'Failed to load application' }); + } +}); + +// GET /application/:id +router.get('/:id', async (req: Request, res: Response) => { + let appID = Number(req.params.id); + let asAdmin = !!req.query.admin || false; + let user = req.user.id; + + //TODO: Replace this with bigger authorization system eventually + if (asAdmin) { + let allowed = (await getUserRoles(user)).some((role) => + role.name.toLowerCase() === 'dev' || + role.name.toLowerCase() === 'recruiter' || + role.name.toLowerCase() === 'administrator') + console.log(allowed) + if (!allowed) { + return res.sendStatus(403) + } + } try { const application = await getApplicationByID(appID); if (application === undefined) return res.sendStatus(204); - const comments: CommentRow[] = await getApplicationComments(appID); + const comments: CommentRow[] = await getApplicationComments(appID, asAdmin); const output: ApplicationFull = { application, @@ -92,9 +148,6 @@ router.post('/approve/:id', async (req, res) => { const app = await getApplicationByID(appID); const result = await approveApplication(appID); - console.log("START"); - console.log(app, result); - //guard against failures if (result.affectedRows != 1) { throw new Error("Something went wrong approving the application"); @@ -119,26 +172,11 @@ router.post('/approve/:id', async (req, res) => { router.post('/deny/:id', async (req, res) => { const appID = req.params.id; - const sql = ` - UPDATE applications - SET denied_at = NOW() - WHERE id = ? - AND approved_at IS NULL - AND denied_at IS NULL - `; try { - const result = await pool.execute(sql, appID); - - console.log(result); - - if (result.affectedRows === 0) { - res.status(400).json('Something went wrong denying the application'); - } - - if (result.affectedRows == 1) { - res.sendStatus(200); - } - + const app = await getApplicationByID(appID); + await denyApplication(appID); + await setUserState(app.member_id, MemberState.Denied); + res.sendStatus(200); } catch (err) { console.error('Approve failed:', err); res.status(500).json({ error: 'Failed to deny application' }); @@ -146,10 +184,12 @@ router.post('/deny/:id', async (req, res) => { }); // POST /application/:id/comment -router.post('/:id/comment', async (req, res) => { +router.post('/:id/comment', async (req: Request, res: Response) => { const appID = req.params.id; const data = req.body.message; - const user = 1; + const user = req.user; + + console.log(user) const sql = `INSERT INTO application_comments( application_id, @@ -161,7 +201,7 @@ VALUES(?, ?, ?);` try { const conn = await pool.getConnection(); - const result = await conn.query(sql, [appID, user, data]) + const result = await conn.query(sql, [appID, user.id, data]) console.log(result) if (result.affectedRows !== 1) { conn.release(); @@ -186,4 +226,60 @@ VALUES(?, ?, ?);` } }); +// POST /application/:id/comment +router.post('/:id/adminComment', async (req: Request, res: Response) => { + const appID = req.params.id; + const data = req.body.message; + const user = req.user; + + console.log(user) + + const sql = `INSERT INTO application_comments( + application_id, + poster_id, + post_content, + admin_only + ) +VALUES(?, ?, ?, 1);` + + try { + const conn = await pool.getConnection(); + + const result = await conn.query(sql, [appID, user.id, data]) + console.log(result) + if (result.affectedRows !== 1) { + conn.release(); + throw new Error("Insert Failure") + } + + const getSQL = `SELECT app.id AS comment_id, + app.post_content, + app.poster_id, + app.post_time, + app.last_modified, + app.admin_only, + member.name AS poster_name + FROM application_comments AS app + INNER JOIN members AS member ON member.id = app.poster_id + WHERE app.id = ?; `; + const comment = await conn.query(getSQL, [result.insertId]) + res.status(201).json(comment[0]); + + } catch (err) { + console.error('Comment failed:', err); + res.status(500).json({ error: 'Could not post comment' }); + } +}); + +router.post('/restart', async (req: Request, res: Response) => { + const user = req.user.id; + try { + await setUserState(user, MemberState.Guest); + res.sendStatus(200); + } catch (error) { + console.error('Comment failed:', error); + res.status(500).json({ error: 'Could not rester application' }); + } +}) + module.exports = router; diff --git a/api/src/services/applicationService.ts b/api/src/services/applicationService.ts index 8e94a08..dceaad3 100644 --- a/api/src/services/applicationService.ts +++ b/api/src/services/applicationService.ts @@ -1,5 +1,6 @@ import { ApplicationListRow, ApplicationRow, CommentRow } from "@app/shared/types/application"; import pool from "../db"; +import { error } from "console"; export async function createApplication(memberID: number, appVersion: number, app: string) { const sql = `INSERT INTO applications (member_id, app_version, app_data) VALUES (?, ?, ?);`; @@ -12,12 +13,13 @@ export async function getMemberApplication(memberID: number): Promise { const sql = `SELECT app.*, @@ -44,7 +46,20 @@ export async function getApplicationList(): Promise { return rows; } -export async function approveApplication(id) { +export async function getAllMemberApplications(memberID: number): Promise { + const sql = `SELECT + app.id, + app.member_id, + app.submitted_at, + app.app_status + FROM applications AS app WHERE app.member_id = ? ORDER BY submitted_at DESC;`; + + const rows: ApplicationListRow[] = await pool.query(sql, [memberID]) + return rows; +} + + +export async function approveApplication(id: number) { const sql = ` UPDATE applications SET approved_at = NOW() @@ -57,15 +72,38 @@ export async function approveApplication(id) { return result; } -export async function getApplicationComments(appID: number): Promise { +export async function denyApplication(id: number) { + const sql = ` + UPDATE applications + SET denied_at = NOW() + WHERE id = ? + AND approved_at IS NULL + AND denied_at IS NULL + `; + + const result = await pool.execute(sql, id); + + if (result.affectedRows == 1) { + return + } else { + throw new Error(`"Something went wrong denying application with ID ${id}`); + } +} + +export async function getApplicationComments(appID: number, admin: boolean = false): Promise { + const excludeAdmin = ' AND app.admin_only = false'; + + const whereClause = `WHERE app.application_id = ?${!admin ? excludeAdmin : ''}`; + return await pool.query(`SELECT app.id AS comment_id, app.post_content, app.poster_id, app.post_time, app.last_modified, + app.admin_only, member.name AS poster_name FROM application_comments AS app INNER JOIN members AS member ON member.id = app.poster_id - WHERE app.application_id = ?;`, + ${whereClause}`, [appID]); } \ No newline at end of file diff --git a/shared/types/application.ts b/shared/types/application.ts index f8b648d..12b0db5 100644 --- a/shared/types/application.ts +++ b/shared/types/application.ts @@ -40,6 +40,7 @@ export interface CommentRow { post_time: string; last_modified: string | null; poster_name: string; + admin_only: boolean; } export interface ApplicationFull { diff --git a/ui/src/api/application.ts b/ui/src/api/application.ts index 85a3dc5..8f60f1e 100644 --- a/ui/src/api/application.ts +++ b/ui/src/api/application.ts @@ -1,80 +1,11 @@ -export type ApplicationDto = Partial<{ - age: number | string - name: string - playtime: number | string - hobbies: string - military: boolean - communities: string - joinReason: string - milsimAttraction: string - referral: string - steamProfile: string - timezone: string - canAttendSaturday: boolean - interests: string - aknowledgeRules: boolean -}> - -export interface ApplicationData { - dob: string; - name: string; - playtime: number; - hobbies: string; - military: boolean; - communities: string; - joinReason: string; - milsimAttraction: string; - referral: string; - steamProfile: string; - timezone: string; - canAttendSaturday: boolean; - interests: string; - aknowledgeRules: boolean; -} - -//reflects how applications are stored in the database -export interface ApplicationRow { - id: number; - member_id: number; - app_version: number; - app_data: ApplicationData; - - submitted_at: string; // ISO datetime from DB (e.g., "2025-08-25T18:04:29.000Z") - updated_at: string | null; - approved_at: string | null; - denied_at: string | null; - - app_status: ApplicationStatus; // generated column - decision_at: string | null; // generated column - - // present when you join members (e.g., SELECT a.*, m.name AS member_name) - member_name: string; -} -export interface CommentRow { - comment_id: number; - post_content: string; - poster_id: number; - post_time: string; - last_modified: string | null; - poster_name: string; -} - -export interface ApplicationFull { - application: ApplicationRow; - comments: CommentRow[]; -} +import { ApplicationFull } from "@shared/types/application"; -export enum ApplicationStatus { - Pending = "Pending", - Accepted = "Accepted", - Denied = "Denied", -} // @ts-ignore const addr = import.meta.env.VITE_APIHOST; -export async function loadApplication(id: number | string): Promise { - const res = await fetch(`${addr}/application/${id}`, { credentials: 'include' }) +export async function loadApplication(id: number | string, asAdmin: boolean = false): Promise { + const res = await fetch(`${addr}/application/${id}?admin=${asAdmin}`, { credentials: 'include' }) if (res.status === 204) return null if (!res.ok) throw new Error('Failed to load application') const json = await res.json() @@ -104,6 +35,22 @@ export async function postChatMessage(message: any, post_id: number) { const response = await fetch(`${addr}/application/${post_id}/comment`, { method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(out), + }) + + return await response.json(); +} + +export async function postAdminChatMessage(message: any, post_id: number) { + const out = { + message: message + } + + const response = await fetch(`${addr}/application/${post_id}/adminComment`, { + method: 'POST', + credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(out), }) @@ -121,6 +68,26 @@ export async function getAllApplications(): Promise { } } +export async function loadMyApplications(): Promise { + const res = await fetch(`${addr}/application/meList`, { credentials: 'include' }) + + if (res.ok) { + return res.json() + } else { + console.error("Something went wrong approving the application") + } +} + +export async function getMyApplication(id: number): Promise { + const res = await fetch(`${addr}/application/me/${id}`, { credentials: 'include' }) + if (res.status === 204) return null + if (res.status === 403) throw new Error("Unauthorized"); + if (!res.ok) throw new Error('Failed to load application') + const json = await res.json() + // Accept either the object at root or under `application` + return json; +} + export async function approveApplication(id: Number) { const res = await fetch(`${addr}/application/approve/${id}`, { method: 'POST' }) @@ -135,4 +102,15 @@ export async function denyApplication(id: Number) { if (!res.ok) { console.error("Something went wrong denying the application") } +} + +export async function restartApplication() { + const res = await fetch(`${addr}/application/restart`, { + method: 'POST', + credentials: 'include' + }) + + if (!res.ok) { + console.error("Something went wrong restarting your application") + } } \ No newline at end of file diff --git a/ui/src/components/Navigation/Navbar.vue b/ui/src/components/Navigation/Navbar.vue index c56d592..8d36850 100644 --- a/ui/src/components/Navigation/Navbar.vue +++ b/ui/src/components/Navigation/Navbar.vue @@ -176,6 +176,7 @@ function blurAfter() { My Application + Application History Logout diff --git a/ui/src/components/application/ApplicationChat.vue b/ui/src/components/application/ApplicationChat.vue index cc1d653..2501d50 100644 --- a/ui/src/components/application/ApplicationChat.vue +++ b/ui/src/components/application/ApplicationChat.vue @@ -11,13 +11,18 @@ import { import Textarea from '@/components/ui/textarea/Textarea.vue' import { toTypedSchema } from '@vee-validate/zod' import * as z from 'zod' +import { useAuth } from '@/composables/useAuth' +import { CommentRow } from '@shared/types/application' +import { Dot } from 'lucide-vue-next' +import { ref } from 'vue' const props = defineProps<{ - messages: Array> + messages: CommentRow[] }>() const emit = defineEmits<{ (e: 'post', text: string): void + (e: 'postInternal', text: string): void }>() const commentSchema = toTypedSchema( @@ -26,9 +31,14 @@ const commentSchema = toTypedSchema( }) ) +const submitMode = ref("public"); + // vee-validate passes (values, actions) to @submit function onSubmit(values: { text: string }, { resetForm }: { resetForm: () => void }) { - emit('post', values.text.trim()) + if (submitMode.value === "internal") + emit('postInternal', values.text.trim()) + else + emit('post', values.text.trim()) resetForm() } @@ -48,25 +58,31 @@ function onSubmit(values: { text: string }, { resetForm }: { resetForm: () => vo -
- +
+ +
-
+
-

{{ message.poster_name }}

+
+

{{ message.poster_name }}

+

+ Internal +

+

{{ new Date(message.post_time).toLocaleString("EN-us", { year: "numeric", month: "long", day: "numeric", hour: "2-digit", minute: "2-digit" - }) }}

+ }) }}

{{ message.post_content }}

diff --git a/ui/src/components/application/ApplicationForm.vue b/ui/src/components/application/ApplicationForm.vue index 6ab7da9..eb1a85f 100644 --- a/ui/src/components/application/ApplicationForm.vue +++ b/ui/src/components/application/ApplicationForm.vue @@ -16,23 +16,27 @@ import { Form } from 'vee-validate'; import { onMounted, ref } from 'vue'; import * as z from 'zod'; import DateInput from '../form/DateInput.vue'; -import { ApplicationData } from '@/api/application'; +import { ApplicationData } from '@shared/types/application'; + +const regexA = /^https?:\/\/steamcommunity\.com\/id\/[A-Za-z0-9_]+\/?$/; +const regexB = /^https?:\/\/steamcommunity\.com\/profiles\/\d+\/?$/; + const formSchema = toTypedSchema(z.object({ dob: z.string().refine(v => v, { message: "A date of birth is required." }), - name: z.string(), + name: z.string().nonempty(), playtime: z.coerce.number({ invalid_type_error: "Must be a number", }).min(0, "Cannot be less than 0"), - hobbies: z.string(), + hobbies: z.string().nonempty(), military: z.boolean(), - communities: z.string(), - joinReason: z.string(), - milsimAttraction: z.string(), - referral: z.string(), - steamProfile: z.string(), - timezone: z.string(), + communities: z.string().nonempty(), + joinReason: z.string().nonempty(), + milsimAttraction: z.string().nonempty(), + referral: z.string().nonempty(), + steamProfile: z.string().nonempty().refine((val) => regexA.test(val) || regexB.test(val), { message: "Invalid Steam profile URL." }), + timezone: z.string().nonempty(), canAttendSaturday: z.boolean(), - interests: z.string(), - aknowledgeRules: z.literal(true, { + interests: z.string().nonempty(), + acknowledgeRules: z.literal(true, { errorMap: () => ({ message: "Required" }) }), })) @@ -41,7 +45,7 @@ const formSchema = toTypedSchema(z.object({ const fallbackInitials = { military: false, canAttendSaturday: false, - aknowledgeRules: false, + acknowledgeRules: false, } const props = defineProps<{ @@ -82,7 +86,9 @@ onMounted(() => { - +
+ +
@@ -94,7 +100,9 @@ onMounted(() => { - +
+ +
@@ -105,7 +113,9 @@ onMounted(() => { - +
+ +
@@ -117,7 +127,9 @@ onMounted(() => {