Discussion-and-Mod-Requests #199
@@ -105,6 +105,7 @@ import { calendarRouter } from './routes/calendar';
|
||||
import { docsRouter } from './routes/docs';
|
||||
import { units } from './routes/units';
|
||||
import { modRequestRouter } from './routes/modRequest'
|
||||
import { discussionRouter } from './routes/discussion';
|
||||
|
||||
app.use('/application', applicationRouter);
|
||||
app.use('/ranks', ranks);
|
||||
@@ -121,6 +122,7 @@ app.use('/calendar', calendarRouter)
|
||||
app.use('/units', units)
|
||||
app.use('/docs', docsRouter)
|
||||
app.use('/mod-request', modRequestRouter)
|
||||
app.use('/discussions', discussionRouter)
|
||||
app.use('/', authRouter)
|
||||
|
||||
app.get('/ping', (req, res) => {
|
||||
|
||||
35
api/src/routes/discussion.ts
Normal file
35
api/src/routes/discussion.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
|
||||
import { Request, Response } from 'express';
|
||||
import { requireLogin, requireMemberState, requireRole } from '../middleware/auth';
|
||||
import { logger } from '../services/logging/logger';
|
||||
import { audit } from '../services/logging/auditLog';
|
||||
import { MemberState } from '@app/shared/types/member';
|
||||
import { createDiscussion, getAllDiscussions, getDiscussionById, postComment } from '../services/db/discussionService';
|
||||
import { ModRequest } from '@app/shared/schemas/modRequest';
|
||||
import { DiscussionComment } from '@app/shared/types/discussion';
|
||||
|
||||
router.use(requireLogin);
|
||||
router.use(requireMemberState(MemberState.Member));
|
||||
|
||||
router.post('/comment', async (req: Request, res: Response) => {
|
||||
try {
|
||||
let comment = req.body as DiscussionComment;
|
||||
|
||||
console.log(comment);
|
||||
|
||||
await postComment(comment, req.user.id);
|
||||
|
||||
res.sendStatus(201);
|
||||
} catch (error) {
|
||||
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/comment/:id', async (req: Request, res: Response) => {
|
||||
|
||||
})
|
||||
|
||||
|
||||
export const discussionRouter = router;
|
||||
@@ -1,10 +1,9 @@
|
||||
|
||||
|
||||
import { toDateTime } from "@app/shared/utils/time";
|
||||
import pool from "../../db";
|
||||
import { LOARequest, LOAType } from '@app/shared/types/loa'
|
||||
import { PagedData } from '@app/shared/types/pagination'
|
||||
import { DiscussionPost } from '@app/shared/types/discussion';
|
||||
import { DiscussionComment } from '@app/shared/types/discussion';
|
||||
|
||||
/**
|
||||
* Retrieves all discussion posts with pagination and optional type filtering.
|
||||
@@ -98,7 +97,8 @@ export async function createDiscussion<T>(type: string, authorID: number, postTi
|
||||
* @returns {Promise<DiscussionPost<T> | null>} The discussion post or null if not found
|
||||
*/
|
||||
export async function getDiscussionById<T>(id: number): Promise<DiscussionPost<T> | null> {
|
||||
const sql = `
|
||||
// Get the post
|
||||
const postSql = `
|
||||
SELECT
|
||||
p.*,
|
||||
m.name as poster_name
|
||||
@@ -107,14 +107,42 @@ export async function getDiscussionById<T>(id: number): Promise<DiscussionPost<T
|
||||
WHERE p.id = ?
|
||||
LIMIT 1;
|
||||
`;
|
||||
|
||||
const results = (await pool.query(sql, [id])) as DiscussionPost<T>[];
|
||||
if (results.length === 0) {
|
||||
const postResults = (await pool.query(postSql, [id])) as DiscussionPost<T>[];
|
||||
if (postResults.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return results[0];
|
||||
const post = postResults[0];
|
||||
|
||||
// Get comments for the post
|
||||
const commentSql = `
|
||||
SELECT
|
||||
c.*
|
||||
FROM discussion_comments AS c
|
||||
WHERE c.post_id = ?
|
||||
AND c.is_deleted = FALSE
|
||||
ORDER BY c.created_at ASC;
|
||||
`;
|
||||
const comments = (await pool.query(commentSql, [id])) as DiscussionComment[];
|
||||
|
||||
// Attach comments to post
|
||||
post.comments = comments;
|
||||
|
||||
return post;
|
||||
}
|
||||
|
||||
export async function postComment() {
|
||||
|
||||
export async function getPostComments(postID: number): Promise<DiscussionComment[]> {
|
||||
let comments = await pool.query("SELECT * FROM discussion_comments WHERE post_id = ?", [postID]);
|
||||
return comments;
|
||||
}
|
||||
|
||||
export async function postComment(commentData: DiscussionComment, poster: number) {
|
||||
const sql = `
|
||||
INSERT INTO discussion_comments (post_id, poster_id, content) VALUES (?, ?, ?);
|
||||
`;
|
||||
|
||||
const result = await pool.query(sql, [commentData.post_id, poster, commentData.content]);
|
||||
|
||||
if (!result.affectedRows || result.affectedRows !== 1) {
|
||||
throw new Error('Failed to insert comment: expected 1 row to be inserted');
|
||||
}
|
||||
}
|
||||
@@ -16,9 +16,9 @@ export interface DiscussionPost<T = any> {
|
||||
export interface DiscussionComment {
|
||||
id?: number;
|
||||
post_id: number;
|
||||
poster_id: number;
|
||||
poster_id?: number;
|
||||
content: string;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
is_deleted: boolean;
|
||||
created_at?: Date;
|
||||
updated_at?: Date;
|
||||
is_deleted?: boolean;
|
||||
}
|
||||
22
ui/src/api/discussion.ts
Normal file
22
ui/src/api/discussion.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { DiscussionComment } from "@shared/types/discussion";
|
||||
|
||||
//@ts-expect-error
|
||||
const addr = import.meta.env.VITE_APIHOST;
|
||||
|
||||
export async function postComment(comment: DiscussionComment) {
|
||||
const res = await fetch(`${addr}/discussions/comment`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(comment),
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
return;
|
||||
} else {
|
||||
throw new Error("Failed to submit LOA");
|
||||
}
|
||||
}
|
||||
|
||||
42
ui/src/components/discussion/CommentForm.vue
Normal file
42
ui/src/components/discussion/CommentForm.vue
Normal file
@@ -0,0 +1,42 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import Textarea from '../ui/textarea/Textarea.vue';
|
||||
import Button from '../ui/button/Button.vue';
|
||||
import { postComment } from '@/api/discussion';
|
||||
import { DiscussionComment } from '@shared/types/discussion';
|
||||
|
||||
const props = defineProps<{
|
||||
parentId: number,
|
||||
}>();
|
||||
|
||||
const emit = defineEmits(['commentPosted']);
|
||||
const commentText = ref('');
|
||||
const error = ref('');
|
||||
|
||||
async function submitComment(e: Event) {
|
||||
e.preventDefault();
|
||||
error.value = '';
|
||||
if (!commentText.value.trim()) {
|
||||
error.value = 'Comment cannot be empty';
|
||||
return;
|
||||
}
|
||||
let newComment: DiscussionComment = { post_id: props.parentId, content: commentText.value.trim() };
|
||||
await postComment(newComment);
|
||||
emit('commentPosted');
|
||||
commentText.value = '';
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form @submit="submitComment">
|
||||
<div class="mb-2">
|
||||
<label class="block font-medium mb-1">Add a comment</label>
|
||||
<Textarea rows="3" class="bg-neutral-800 resize-none w-full" v-model="commentText"
|
||||
placeholder="Add a comment…" />
|
||||
<div class="h-4 text-red-500 text-sm mt-1" v-if="error">{{ error }}</div>
|
||||
</div>
|
||||
<div class="mt-2 flex justify-end">
|
||||
<Button type="submit" :disabled="!commentText.trim()">Post Comment</Button>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
31
ui/src/components/discussion/CommentThread.vue
Normal file
31
ui/src/components/discussion/CommentThread.vue
Normal file
@@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
import { DiscussionComment } from '@shared/types/discussion';
|
||||
import DiscussionCommentView from './DiscussionCommentView.vue';
|
||||
import CommentForm from './CommentForm.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
parentId: number;
|
||||
comments: DiscussionComment[];
|
||||
}>();
|
||||
|
||||
function onCommentPosted() {
|
||||
// TODO: Refresh comments if needed
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mt-10 *:my-5">
|
||||
<h3 class="text-xl font-semibold">Discussion</h3>
|
||||
<div class="comment-thread">
|
||||
<div class="comments-list flex flex-col gap-5">
|
||||
<div v-for="comment in comments" :key="comment.id" class="comment">
|
||||
<DiscussionCommentView :comment="comment"></DiscussionCommentView>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CommentForm :parent-id="props.parentId" @commentPosted="onCommentPosted" />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
26
ui/src/components/discussion/DiscussionCommentView.vue
Normal file
26
ui/src/components/discussion/DiscussionCommentView.vue
Normal file
@@ -0,0 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
import { DiscussionComment } from '@shared/types/discussion';
|
||||
import MemberCard from '../members/MemberCard.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
comment: DiscussionComment;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
<div class="rounded-md border border-neutral-800 p-3 space-y-5">
|
||||
<!-- Comment header -->
|
||||
<div class="flex justify-between">
|
||||
<MemberCard :member-id="comment.poster_id"></MemberCard>
|
||||
<p class="text-muted-foreground">{{ new Date(comment.created_at).toLocaleString("EN-us", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit"
|
||||
}) }}</p>
|
||||
</div>
|
||||
<p>{{ comment.content }}</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -6,6 +6,7 @@
|
||||
import { getModRequest } from '@/api/modRequests';
|
||||
import Button from '@/components/ui/button/Button.vue';
|
||||
import Badge from '@/components/ui/badge/Badge.vue';
|
||||
import CommentThread from '../discussion/CommentThread.vue';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
@@ -41,7 +42,7 @@
|
||||
<div class="max-w-[80rem] mt-5 mx-auto px-2 lg:px-20 w-full">
|
||||
<div class="mb-8 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="scroll-m-20 text-3xl font-semibold tracking-tight mb-2">Mod Request: {{ post.title }}</h1>
|
||||
<h1 class="scroll-m-20 text-3xl font-semibold tracking-tight mb-2">Mod Request: {{ post?.title }}</h1>
|
||||
<p class="text-muted-foreground" v-if="post">
|
||||
Requested by {{ post.poster_name || 'Unknown' }} on {{ new
|
||||
Date(post.created_at).toLocaleDateString() }}
|
||||
@@ -95,10 +96,8 @@
|
||||
</div>
|
||||
|
||||
<!-- discussion placeholder -->
|
||||
<div class="mt-10">
|
||||
<h3 class="text-xl font-semibold">Discussion</h3>
|
||||
<p class="text-muted-foreground">Comments and thread will appear here once implemented.</p>
|
||||
</div>
|
||||
<CommentThread :parent-id="post.id" :comments="post.comments" />
|
||||
|
||||
</div>
|
||||
<div v-else>
|
||||
<p class="text-muted-foreground">Unable to locate this mod request.</p>
|
||||
|
||||
Reference in New Issue
Block a user