Implemented comment viewing and posting
This commit is contained in:
@@ -105,6 +105,7 @@ import { calendarRouter } from './routes/calendar';
|
|||||||
import { docsRouter } from './routes/docs';
|
import { docsRouter } from './routes/docs';
|
||||||
import { units } from './routes/units';
|
import { units } from './routes/units';
|
||||||
import { modRequestRouter } from './routes/modRequest'
|
import { modRequestRouter } from './routes/modRequest'
|
||||||
|
import { discussionRouter } from './routes/discussion';
|
||||||
|
|
||||||
app.use('/application', applicationRouter);
|
app.use('/application', applicationRouter);
|
||||||
app.use('/ranks', ranks);
|
app.use('/ranks', ranks);
|
||||||
@@ -121,6 +122,7 @@ app.use('/calendar', calendarRouter)
|
|||||||
app.use('/units', units)
|
app.use('/units', units)
|
||||||
app.use('/docs', docsRouter)
|
app.use('/docs', docsRouter)
|
||||||
app.use('/mod-request', modRequestRouter)
|
app.use('/mod-request', modRequestRouter)
|
||||||
|
app.use('/discussions', discussionRouter)
|
||||||
app.use('/', authRouter)
|
app.use('/', authRouter)
|
||||||
|
|
||||||
app.get('/ping', (req, res) => {
|
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 { toDateTime } from "@app/shared/utils/time";
|
||||||
import pool from "../../db";
|
import pool from "../../db";
|
||||||
import { LOARequest, LOAType } from '@app/shared/types/loa'
|
import { LOARequest, LOAType } from '@app/shared/types/loa'
|
||||||
import { PagedData } from '@app/shared/types/pagination'
|
import { PagedData } from '@app/shared/types/pagination'
|
||||||
import { DiscussionPost } from '@app/shared/types/discussion';
|
import { DiscussionPost } from '@app/shared/types/discussion';
|
||||||
|
import { DiscussionComment } from '@app/shared/types/discussion';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrieves all discussion posts with pagination and optional type filtering.
|
* 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
|
* @returns {Promise<DiscussionPost<T> | null>} The discussion post or null if not found
|
||||||
*/
|
*/
|
||||||
export async function getDiscussionById<T>(id: number): Promise<DiscussionPost<T> | null> {
|
export async function getDiscussionById<T>(id: number): Promise<DiscussionPost<T> | null> {
|
||||||
const sql = `
|
// Get the post
|
||||||
|
const postSql = `
|
||||||
SELECT
|
SELECT
|
||||||
p.*,
|
p.*,
|
||||||
m.name as poster_name
|
m.name as poster_name
|
||||||
@@ -107,14 +107,42 @@ export async function getDiscussionById<T>(id: number): Promise<DiscussionPost<T
|
|||||||
WHERE p.id = ?
|
WHERE p.id = ?
|
||||||
LIMIT 1;
|
LIMIT 1;
|
||||||
`;
|
`;
|
||||||
|
const postResults = (await pool.query(postSql, [id])) as DiscussionPost<T>[];
|
||||||
const results = (await pool.query(sql, [id])) as DiscussionPost<T>[];
|
if (postResults.length === 0) {
|
||||||
if (results.length === 0) {
|
|
||||||
return null;
|
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 {
|
export interface DiscussionComment {
|
||||||
id?: number;
|
id?: number;
|
||||||
post_id: number;
|
post_id: number;
|
||||||
poster_id: number;
|
poster_id?: number;
|
||||||
content: string;
|
content: string;
|
||||||
created_at: Date;
|
created_at?: Date;
|
||||||
updated_at: Date;
|
updated_at?: Date;
|
||||||
is_deleted: boolean;
|
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 { getModRequest } from '@/api/modRequests';
|
||||||
import Button from '@/components/ui/button/Button.vue';
|
import Button from '@/components/ui/button/Button.vue';
|
||||||
import Badge from '@/components/ui/badge/Badge.vue';
|
import Badge from '@/components/ui/badge/Badge.vue';
|
||||||
|
import CommentThread from '../discussion/CommentThread.vue';
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
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="max-w-[80rem] mt-5 mx-auto px-2 lg:px-20 w-full">
|
||||||
<div class="mb-8 flex items-center justify-between">
|
<div class="mb-8 flex items-center justify-between">
|
||||||
<div>
|
<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">
|
<p class="text-muted-foreground" v-if="post">
|
||||||
Requested by {{ post.poster_name || 'Unknown' }} on {{ new
|
Requested by {{ post.poster_name || 'Unknown' }} on {{ new
|
||||||
Date(post.created_at).toLocaleDateString() }}
|
Date(post.created_at).toLocaleDateString() }}
|
||||||
@@ -95,10 +96,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- discussion placeholder -->
|
<!-- discussion placeholder -->
|
||||||
<div class="mt-10">
|
<CommentThread :parent-id="post.id" :comments="post.comments" />
|
||||||
<h3 class="text-xl font-semibold">Discussion</h3>
|
|
||||||
<p class="text-muted-foreground">Comments and thread will appear here once implemented.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div v-else>
|
<div v-else>
|
||||||
<p class="text-muted-foreground">Unable to locate this mod request.</p>
|
<p class="text-muted-foreground">Unable to locate this mod request.</p>
|
||||||
|
|||||||
Reference in New Issue
Block a user