Integrated application chat system

This commit is contained in:
2025-08-26 13:38:17 -04:00
parent 342c6cd706
commit e43117b64f
6 changed files with 174 additions and 76 deletions

View File

@@ -44,35 +44,42 @@ app.post('/application', async (req, res) => {
}); });
app.get('/application/me', async (req, res) => { // app.get('/application/me', async (req, res) => {
try { // try {
// TODO: replace with current user ID // // TODO: replace with current user ID
const applicationId = 1; // const applicationId = 1;
const rows = await pool.query( // const rows = await pool.query(
`SELECT app.*, // `SELECT app.*,
member.name AS member_name // member.name AS member_name
FROM applications AS app // FROM applications AS app
INNER JOIN members AS member ON member.id = app.member_id // INNER JOIN members AS member ON member.id = app.member_id
WHERE app.member_id = ?;`, // WHERE app.member_id = ?;`,
[applicationId] // [applicationId]
); // );
if (!Array.isArray(rows) || rows.length === 0) { // if (!Array.isArray(rows) || rows.length === 0) {
return res.sendStatus(204); // return res.sendStatus(204);
} // }
return res.status(200).json(rows[0]); // return res.status(200).json(rows[0]);
} catch (err) { // } catch (err) {
console.error('Query failed:', err); // console.error('Query failed:', err);
return res.status(500).json({ error: 'Failed to load application' }); // return res.status(500).json({ error: 'Failed to load application' });
} // }
}); // });
app.get('/application/:id', async (req, res) => { app.get('/application/:id', async (req, res) => {
const appID = req.params.id; let appID = req.params.id;
//TODO: Replace with real user Authorization and whatnot
if (appID === "me")
appID = 1;
try { try {
const rows = await pool.query( const conn = await pool.getConnection()
const application = await conn.query(
`SELECT app.*, `SELECT app.*,
member.name AS member_name member.name AS member_name
FROM applications AS app FROM applications AS app
@@ -81,11 +88,29 @@ app.get('/application/:id', async (req, res) => {
[appID] [appID]
); );
if (!Array.isArray(rows) || rows.length === 0) { if (!Array.isArray(application) || application.length === 0) {
return res.send(404).json("Application Not Found"); conn.release();
return res.status(204).json("Application Not Found");
} }
return res.status(200).json(rows[0]); const comments = await conn.query(`SELECT app.id AS comment_id,
app.post_content,
app.poster_id,
app.post_time,
app.last_modified,
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 = ?;`,
[appID]);
conn.release()
const output = {
application: application[0],
comments,
}
return res.status(200).json(output);
} }
catch (err) { catch (err) {
console.error('Query failed:', err); console.error('Query failed:', err);
@@ -115,12 +140,6 @@ app.get('/application/all', async (req, res) => {
} }
}); });
app.post('/application/message', (req, res) => {
const data = req.body;
applicationData.messages.push(data);
res.status(200).send();
});
app.post('/application/approve/:id', async (req, res) => { app.post('/application/approve/:id', async (req, res) => {
const appID = req.params.id; const appID = req.params.id;
@@ -179,6 +198,46 @@ app.post('/application/deny/:id', async (req, res) => {
} }
}); });
app.post('/application/:id/comment', async (req, res) => {
const appID = req.params.id;
const data = req.body.message;
const user = 1;
const sql = `INSERT INTO application_comments(
application_id,
poster_id,
post_content
)
VALUES(?, ?, ?);`
try {
const conn = await pool.getConnection();
const result = await conn.query(sql, [appID, user, 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,
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' });
}
})
app.listen(port, () => { app.listen(port, () => {
console.log(`Example app listening on port ${port}`) console.log(`Example app listening on port ${port} `)
}) })

View File

@@ -17,7 +17,7 @@ import ManageApplications from './pages/ManageApplications.vue';
</div> </div>
<Separator></Separator> <Separator></Separator>
<Application></Application> <Application></Application>
<ManageApplications></ManageApplications> <!-- <ManageApplications></ManageApplications> -->
<!-- <AutoForm class="max-w-3xl mx-auto my-20"></AutoForm> --> <!-- <AutoForm class="max-w-3xl mx-auto my-20"></AutoForm> -->
</div> </div>
</template> </template>

View File

@@ -49,14 +49,24 @@ export interface ApplicationRow {
// present when you join members (e.g., SELECT a.*, m.name AS member_name) // present when you join members (e.g., SELECT a.*, m.name AS member_name)
member_name: string; 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[];
}
export type ApplicationFull = ApplicationRow & {
messages?: object[];
};
export enum Status { export enum Status {
Pending = "Pending", Pending = "Pending",
Approved = "Approved", Accepted = "Accepted",
Denied = "Denied", Denied = "Denied",
} }
@@ -83,19 +93,19 @@ export async function postApplication(val: any) {
}) })
} }
export async function postChatMessage(val: any) { export async function postChatMessage(message: any, post_id: number) {
let output = { const out = {
message: val, message: message
sender: 1,
timestamp: Date.now(),
} }
await fetch(`http://${addr}/application/message`, { const response = await fetch(`http://${addr}/application/${post_id}/comment`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(output), body: JSON.stringify(out),
}) })
return await response.json();
} }
export async function getAllApplications() { export async function getAllApplications() {

View File

@@ -40,12 +40,8 @@ function onSubmit(values: { text: string }, { resetForm }: { resetForm: () => vo
<FormItem> <FormItem>
<FormLabel class="sr-only">Comment</FormLabel> <FormLabel class="sr-only">Comment</FormLabel>
<FormControl> <FormControl>
<Textarea <Textarea v-bind="componentField" rows="3" placeholder="Write a comment…"
v-bind="componentField" class="bg-neutral-800 resize-none w-full" />
rows="3"
placeholder="Write a comment…"
class="bg-neutral-800 resize-none w-full"
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -59,12 +55,20 @@ function onSubmit(values: { text: string }, { resetForm }: { resetForm: () => vo
<!-- Existing posts --> <!-- Existing posts -->
<div class="space-y-3"> <div class="space-y-3">
<div <div v-for="(message, i) in props.messages" :key="message.id ?? i"
v-for="(m, i) in props.messages" class="rounded-md border border-neutral-800 p-3 space-y-5">
:key="m.id ?? i" <!-- Comment header -->
class="rounded-md border border-neutral-800 p-3" <div class="flex justify-between">
> <p>{{ message.poster_name }}</p>
<p class="text-sm whitespace-pre-wrap">{{ m.text ?? m.message ?? '' }}</p> <p class="text-muted-foreground">{{ new Date(message.post_time).toLocaleString("EN-us", {
year: "numeric",
month: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit"
}) }}</p>
</div>
<p>{{ message.post_content }}</p>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -5,9 +5,10 @@ import { onMounted, ref } from 'vue';
import { ApplicationData, loadApplication, postApplication, postChatMessage, Status } from '@/api/application'; import { ApplicationData, loadApplication, postApplication, postChatMessage, Status } from '@/api/application';
const appData = ref<ApplicationData>(null); const appData = ref<ApplicationData>(null);
const appID = ref<number | null>(null);
const chatData = ref<object[]>([]) const chatData = ref<object[]>([])
const readOnly = ref<boolean>(false); const readOnly = ref<boolean>(false);
const newApp = ref<boolean>(true); const newApp = ref<boolean>(null);
const status = ref<Status>(null); const status = ref<Status>(null);
const decisionDate = ref<Date | null>(null); const decisionDate = ref<Date | null>(null);
const submitDate = ref<Date | null>(null); const submitDate = ref<Date | null>(null);
@@ -15,19 +16,25 @@ const loading = ref<boolean>(true);
const member_name = ref<string>(); const member_name = ref<string>();
onMounted(async () => { onMounted(async () => {
try { try {
const data = await loadApplication() const raw = await loadApplication()
console.log(data); if (raw === null) {
if (data) { //new app
appData.value = null
readOnly.value = false;
newApp.value = true;
} else {
//load app
const data = raw.application;
appID.value = data.id;
appData.value = data.app_data; appData.value = data.app_data;
chatData.value = data.messages; chatData.value = raw.comments;
status.value = data.app_status; status.value = data.app_status;
decisionDate.value = new Date(data.decision_at); decisionDate.value = new Date(data.decision_at);
submitDate.value = data.decision_at ? new Date(data.submitted_at) : null; submitDate.value = data.submitted_at ? new Date(data.submitted_at) : null;
member_name.value = data.member_name; member_name.value = data.member_name;
readOnly.value = true;
} else {
appData.value = null
newApp.value = false; newApp.value = false;
readOnly.value = true;
} }
} catch (e) { } catch (e) {
console.error(e) console.error(e)
@@ -35,34 +42,52 @@ onMounted(async () => {
loading.value = false; loading.value = false;
}) })
async function postComment(comment) {
chatData.value.push(await postChatMessage(comment, appID.value));
}
</script> </script>
<template> <template>
<div v-if="!loading" class="max-w-3xl mx-auto my-20"> <div v-if="!loading" class="max-w-3xl mx-auto my-20">
<div v-if="newApp" class="flex flex-row justify-between items-center py-2 mb-8"> <div v-if="!newApp" class="flex flex-row justify-between items-center py-2 mb-8">
<!-- Application header --> <!-- Application header -->
<div> <div>
<h3 class="scroll-m-20 text-2xl font-semibold tracking-tight">{{ member_name }}</h3> <h3 class="scroll-m-20 text-2xl font-semibold tracking-tight">{{ member_name }}</h3>
<p class="text-muted-foreground">Submitted: {{ submitDate.toLocaleString("en-US") }}</p> <p class="text-muted-foreground">Submitted: {{ submitDate.toLocaleString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit"
}) }}</p>
</div> </div>
<div> <div>
<h3 class="text-right" :class="[ <h3 class="text-right" :class="[
'font-semibold', 'font-semibold',
status === Status.Pending && 'text-yellow-500', status === Status.Pending && 'text-yellow-500',
status === Status.Approved && 'text-green-500', status === Status.Accepted && 'text-green-500',
status === Status.Denied && 'text-red-500' status === Status.Denied && 'text-red-500'
]">{{ status }}</h3> ]">{{ status }}</h3>
<p class="text-muted-foreground">{{ status }}: {{ decisionDate.toLocaleString("en-US") }}</p> <p v-if="status != Status.Pending" class="text-muted-foreground">{{ status }}: {{
decisionDate.toLocaleString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit"
}) }}</p>
</div> </div>
</div> </div>
<div v-else class="flex flex-row justify-between items-center py-2 mb-8"> <div v-else class="flex flex-row justify-between items-center py-2 mb-8">
<h3 class="scroll-m-20 text-2xl font-semibold tracking-tight">Apply to join the 17th Rangers</h3> <h3 class="scroll-m-20 text-2xl font-semibold tracking-tight">Apply to join the 17th Rangers</h3>
</div> </div>
<ApplicationForm v-if="appData" :read-only="readOnly" :data="appData" @submit="(e) => { postApplication(e) }" <ApplicationForm :read-only="readOnly" :data="appData" @submit="(e) => { postApplication(e) }" class="mb-7">
class="mb-7"></ApplicationForm> </ApplicationForm>
<div v-if="newApp"> <div v-if="!newApp">
<h3 class="scroll-m-20 text-2xl font-semibold tracking-tight mb-4">Discussion</h3> <h3 class="scroll-m-20 text-2xl font-semibold tracking-tight mb-4">Discussion</h3>
<ApplicationChat :messages="chatData" @post="postChatMessage"></ApplicationChat> <ApplicationChat :messages="chatData" @post="postComment"></ApplicationChat>
</div> </div>
</div> </div>
<div v-else>HELLOOO</div>
</template> </template>

View File

@@ -63,7 +63,7 @@ onMounted(async () => {
}) })
</script> </script>
<template> <template>
<Table> <Table class="mx-auto max-w-3xl">
<!-- <TableCaption>A list of your recent invoices.</TableCaption> --> <!-- <TableCaption>A list of your recent invoices.</TableCaption> -->
<TableHeader> <TableHeader>
<TableRow> <TableRow>
@@ -81,7 +81,7 @@ onMounted(async () => {
<TableCell v-if="app.app_status != 'Pending'" class="text-right" :class="[ <TableCell v-if="app.app_status != 'Pending'" class="text-right" :class="[
'font-semibold', 'font-semibold',
app.app_status === Status.Pending && 'text-yellow-500', app.app_status === Status.Pending && 'text-yellow-500',
app.app_status === Status.Approved && 'text-success', app.app_status === Status.Accepted && 'text-success',
app.app_status === Status.Denied && 'text-destructive' app.app_status === Status.Denied && 'text-destructive'
]">{{ app.app_status }}</TableCell> ]">{{ app.app_status }}</TableCell>
<TableCell v-else class="inline-flex items-end gap-2"> <TableCell v-else class="inline-flex items-end gap-2">