diff --git a/api/migrations/20260222232949-discussion-posts.js b/api/migrations/20260222232949-discussion-posts.js new file mode 100644 index 0000000..3e483f2 --- /dev/null +++ b/api/migrations/20260222232949-discussion-posts.js @@ -0,0 +1,53 @@ +'use strict'; + +var dbm; +var type; +var seed; +var fs = require('fs'); +var path = require('path'); +var Promise; + +/** + * We receive the dbmigrate dependency from dbmigrate initially. + * This enables us to not have to rely on NODE_PATH. + */ +exports.setup = function(options, seedLink) { + dbm = options.dbmigrate; + type = dbm.dataType; + seed = seedLink; + Promise = options.Promise; +}; + +exports.up = function(db) { + var filePath = path.join(__dirname, 'sqls', '20260222232949-discussion-posts-up.sql'); + return new Promise( function( resolve, reject ) { + fs.readFile(filePath, {encoding: 'utf-8'}, function(err,data){ + if (err) return reject(err); + console.log('received data: ' + data); + + resolve(data); + }); + }) + .then(function(data) { + return db.runSql(data); + }); +}; + +exports.down = function(db) { + var filePath = path.join(__dirname, 'sqls', '20260222232949-discussion-posts-down.sql'); + return new Promise( function( resolve, reject ) { + fs.readFile(filePath, {encoding: 'utf-8'}, function(err,data){ + if (err) return reject(err); + console.log('received data: ' + data); + + resolve(data); + }); + }) + .then(function(data) { + return db.runSql(data); + }); +}; + +exports._meta = { + "version": 1 +}; diff --git a/api/migrations/sqls/20260222232949-discussion-posts-down.sql b/api/migrations/sqls/20260222232949-discussion-posts-down.sql new file mode 100644 index 0000000..8914503 --- /dev/null +++ b/api/migrations/sqls/20260222232949-discussion-posts-down.sql @@ -0,0 +1,3 @@ +/* Replace with your SQL commands */ +DROP TABLE discussion_posts; +DROP TABLE discussion_comments; \ No newline at end of file diff --git a/api/migrations/sqls/20260222232949-discussion-posts-up.sql b/api/migrations/sqls/20260222232949-discussion-posts-up.sql new file mode 100644 index 0000000..cb5ce7f --- /dev/null +++ b/api/migrations/sqls/20260222232949-discussion-posts-up.sql @@ -0,0 +1,34 @@ +/* Replace with your SQL commands */ +CREATE TABLE discussion_posts ( + id INT PRIMARY KEY AUTO_INCREMENT, + type VARCHAR(50) NOT NULL, + poster_id INT NOT NULL, + title VARCHAR(100) NOT NULL, + content JSON NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + is_deleted BOOLEAN DEFAULT FALSE, + is_locked BOOLEAN DEFAULT FALSE, + is_open BOOLEAN GENERATED ALWAYS AS ( + NOT is_deleted + AND NOT is_locked + ) STORED, + FOREIGN KEY (poster_id) REFERENCES members(id) ON DELETE CASCADE +); +CREATE TABLE discussion_comments ( + id INT PRIMARY KEY AUTO_INCREMENT, + post_id INT NOT NULL, + poster_id INT NOT NULL, + content TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + is_deleted BOOLEAN DEFAULT FALSE, + FOREIGN KEY (post_id) REFERENCES discussion_posts(id) ON DELETE CASCADE, + FOREIGN KEY (poster_id) REFERENCES members(id) ON DELETE CASCADE +); +CREATE INDEX idx_discussion_posts_title ON discussion_posts(title); +CREATE INDEX idx_discussion_posts_type ON discussion_posts(type); +CREATE INDEX idx_discussion_posts_poster_id ON discussion_posts(poster_id); +CREATE INDEX idx_discussion_comments_post_id ON discussion_comments(post_id); +CREATE INDEX idx_discussion_comments_poster_id ON discussion_comments(poster_id); +CREATE INDEX idx_discussion_posts_is_open ON discussion_posts(is_open); \ No newline at end of file diff --git a/api/src/index.ts b/api/src/index.ts index f6767e2..bda10b5 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -104,6 +104,8 @@ import { courseRouter, eventRouter } from './routes/course'; 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); @@ -119,6 +121,8 @@ app.use('/courseEvent', eventRouter) 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) => { diff --git a/api/src/routes/discussion.ts b/api/src/routes/discussion.ts new file mode 100644 index 0000000..3b1a4bf --- /dev/null +++ b/api/src/routes/discussion.ts @@ -0,0 +1,49 @@ +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, getPostComments, 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; + + if (!comment.content || comment.content.trim() === '') { + return res.status(400).json({ error: 'Comment content cannot be empty' }); + } + + let rowID = await postComment(comment, req.user.id); + audit.discussion('comment_posted', { actorId: req.user.id, targetId: rowID }, { parent: comment.post_id }) + res.sendStatus(201); + } catch (error) { + logger.error('app', "Failed to post comments", error); + res.sendStatus(500); + } +}); + +router.get('/:postId/comments', async (req: Request, res: Response) => { + try { + const postId = parseInt(req.params.postId); + const comments = await getPostComments(postId); + res.json(comments); + } catch (error) { + logger.error('app', "Failed to fetch comments", error); + res.sendStatus(500); + } +}); + +router.delete('/comment/:id', async (req: Request, res: Response) => { + +}) + + +export const discussionRouter = router; \ No newline at end of file diff --git a/api/src/routes/modRequest.ts b/api/src/routes/modRequest.ts new file mode 100644 index 0000000..2934e94 --- /dev/null +++ b/api/src/routes/modRequest.ts @@ -0,0 +1,65 @@ +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 } from '../services/db/discussionService'; +import { ModRequest } from '@app/shared/schemas/modRequest'; + +router.use(requireLogin); +router.use(requireMemberState(MemberState.Member)); + +router.get('/', async (req: Request, res: Response) => { + try { + const page = parseInt(req.query.page as string) || 1; + const pageSize = parseInt(req.query.pageSize as string) || 10; + const search = parseInt(req.query.search as string) || null; + + const result = await getAllDiscussions('mod_request', page, pageSize); + + return res.json(result); + } catch (error) { + console.error('Error fetching mod requests:', error); + res.status(500).json({ error: 'Internal Server Error' }); + } +}); + +// GET a single mod request by ID +router.get('/:id', async (req: Request, res: Response) => { + try { + const id = parseInt(req.params.id, 10); + if (isNaN(id)) { + return res.status(400).json({ error: 'Invalid ID' }); + } + + const discussion = await getDiscussionById(id); + if (!discussion) { + return res.status(404).json({ error: 'Mod request not found' }); + } + + return res.json(discussion); + } catch (error) { + console.error('Error fetching mod request by id:', error); + res.status(500).json({ error: 'Internal Server Error' }); + } +}); + +router.post('/', async (req: Request, res: Response) => { + try { + let author = req.user.id; + let data = req.body as ModRequest; + + let postID = await createDiscussion('mod_request', author, data.mod_title, data); + logger.info('app', 'Mod request posted', {}); + audit.discussion('created', { actorId: author, targetId: postID }, { type: "mod_request" }); + return res.status(200).send(postID); + } catch (error) { + console.error('Error posting a mod request:', error); + res.status(500).json({ error: 'Internal Server Error' }); + } +}) + +export const modRequestRouter = router; \ No newline at end of file diff --git a/api/src/services/db/discussionService.ts b/api/src/services/db/discussionService.ts new file mode 100644 index 0000000..25c4676 --- /dev/null +++ b/api/src/services/db/discussionService.ts @@ -0,0 +1,150 @@ +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. + * @template T - The type of content stored in discussion posts + * @param {string} [type] - Optional type filter to retrieve only posts of a specific type + * @param {number} [page=1] - The page number for pagination (1-indexed) + * @param {number} [pageSize=10] - The number of posts per page + * @returns {Promise>>} A promise that resolves to paginated discussion posts with metadata + * @throws {Error} If the database query fails + */ +export async function getAllDiscussions(type?: string, page = 1, pageSize = 10, search?: string): Promise>> { + const offset = (page - 1) * pageSize; + const params: any[] = []; + + // Base query parts + let whereClause = "WHERE is_deleted = FALSE"; + if (type) { + whereClause += " AND type = ?"; + params.push(type); + } + + const sql = ` + SELECT + p.*, + m.name as poster_name + FROM discussion_posts AS p + LEFT JOIN members m ON p.poster_id = m.id + ${whereClause} + ORDER BY + p.is_open DESC, -- Show active/unlocked threads first + p.created_at DESC -- Then show newest first + LIMIT ? OFFSET ?; + `; + + // Add pagination params to the end + params.push(pageSize, offset); + + // Execute queries + const posts: DiscussionPost[] = await pool.query(sql, params) as DiscussionPost[]; + + // Get count for the specific types + const countSql = `SELECT COUNT(*) as count FROM discussion_posts ${whereClause}`; + const countResult = await pool.query(countSql, type ? [type] : []); + const totalCount = Number(countResult[0].count); + + const totalPages = Math.ceil(totalCount / pageSize); + + return { + data: posts, + pagination: { + page, + pageSize, + total: totalCount, + totalPages + } + }; +} + +/** + * Creates a new discussion post. + * @template T - The type of content for the discussion post + * @param {string} type - The type/category of the discussion post + * @param {number} authorID - The ID of the member creating the post + * @param {postTitle} string - The title of the discussion post + * @param {T} data - The content data to be stored in the post + * @returns {Promise} A promise that resolves to the ID of the newly created post + * @throws {Error} If the database insertion fails + */ +export async function createDiscussion(type: string, authorID: number, postTitle: string, data: T): Promise { + const sql = ` + INSERT INTO discussion_posts (type, poster_id, title, content) + VALUES (?, ?, ?, ?) + `; + + console.log(data); + const result = await pool.query(sql, [ + type, + authorID, + postTitle, + JSON.stringify(data) + ]); + + console.log(result); + return Number(result.insertId); +} + +/** + * Retrieve a single discussion post by its ID. + * @template T - type of the content stored in the post (e.g. ModRequest) + * @param {number} id - The id of the discussion post to fetch + * @returns {Promise | null>} The discussion post or null if not found + */ +export async function getDiscussionById(id: number): Promise | null> { + // Get the post + const postSql = ` + SELECT + p.*, + m.name as poster_name + FROM discussion_posts AS p + LEFT JOIN members m ON p.poster_id = m.id + WHERE p.id = ? + LIMIT 1; + `; + const postResults = (await pool.query(postSql, [id])) as DiscussionPost[]; + if (postResults.length === 0) { + return null; + } + 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 getPostComments(postID: number): Promise { + 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'); + } + + return Number(result.insertId); +} \ No newline at end of file diff --git a/api/src/services/logging/auditLog.ts b/api/src/services/logging/auditLog.ts index fded7ca..f2fab6a 100644 --- a/api/src/services/logging/auditLog.ts +++ b/api/src/services/logging/auditLog.ts @@ -1,7 +1,7 @@ import pool from "../../db"; import { logger } from "./logger"; -export type AuditArea = 'member' | 'calendar' | 'roles' | 'auth' | 'leave_of_absence' | 'application' | 'course'; +export type AuditArea = 'member' | 'calendar' | 'roles' | 'auth' | 'leave_of_absence' | 'application' | 'course' | 'discussion'; export interface AuditContext { actorId: number; // The person doing the action (created_by) @@ -56,6 +56,10 @@ class AuditLogger { course(action: 'report_created' | 'report_edited', context: AuditContext, data: any = {}) { return this.record('course', action, context, data); } + + discussion(action: 'created' | 'comment_posted', context: AuditContext, data: any = {}) { + return this.record('discussion', action, context, data); + } } export const audit = new AuditLogger(); \ No newline at end of file diff --git a/shared/schemas/modRequest.ts b/shared/schemas/modRequest.ts new file mode 100644 index 0000000..0cf6d7b --- /dev/null +++ b/shared/schemas/modRequest.ts @@ -0,0 +1,24 @@ +import { z } from 'zod'; + +export const ModRequestSchema = z.object({ + // Basic Info + mod_title: z.string().min(1), + description: z.string().min(1), + mod_link: z.string().min(1), + + // Consolidated Testing + confirmed_tested: z.boolean().refine(val => val === true, { + message: "You must confirm that you have tested this mod before submitting" + }), + + // Vetting + reason: z.string().min(1), + + // Compatibility & Technical + detrimental_effects: z.string().min(1), + keybind_conflicts: z.string(), + + special_considerations: z.string().optional() +}); + +export type ModRequest = z.infer; \ No newline at end of file diff --git a/shared/types/discussion.ts b/shared/types/discussion.ts new file mode 100644 index 0000000..3ef2ef3 --- /dev/null +++ b/shared/types/discussion.ts @@ -0,0 +1,24 @@ +export interface DiscussionPost { + id: number; + type: string; + poster_id: number; + poster_name?: string; + title: string; + content: T; + created_at: Date; + updated_at: Date; + is_deleted: boolean; + is_locked: boolean; + is_open: boolean; + comments?: DiscussionComment[]; +} + +export interface DiscussionComment { + id?: number; + post_id: number; + poster_id?: number; + content: string; + created_at?: Date; + updated_at?: Date; + is_deleted?: boolean; +} \ No newline at end of file diff --git a/ui/src/api/discussion.ts b/ui/src/api/discussion.ts new file mode 100644 index 0000000..8e682b9 --- /dev/null +++ b/ui/src/api/discussion.ts @@ -0,0 +1,36 @@ +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"); + } +} + +export async function getPostComments(postId: number): Promise { + const res = await fetch(`${addr}/discussions/${postId}/comments`, { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + credentials: 'include', + }); + if (!res.ok) { + throw new Error("Failed to fetch comments"); + } + return res.json(); +} + diff --git a/ui/src/api/modRequests.ts b/ui/src/api/modRequests.ts new file mode 100644 index 0000000..28d6bad --- /dev/null +++ b/ui/src/api/modRequests.ts @@ -0,0 +1,72 @@ +import { ModRequest } from "@shared/schemas/modRequest"; +import { DiscussionPost } from "@shared/types/discussion"; +import { PagedData } from "@shared/types/pagination"; + +//@ts-expect-error +const addr = import.meta.env.VITE_APIHOST; + +export async function getModRequests(page?: number, pageSize?: number): Promise>> { + const params = new URLSearchParams(); + + if (page !== undefined) { + params.set("page", page.toString()); + } + + if (pageSize !== undefined) { + params.set("pageSize", pageSize.toString()); + } + + return fetch(`${addr}/mod-request?${params}`, { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + credentials: 'include', + }).then((res) => { + if (res.ok) { + return res.json(); + } else { + return []; + } + }); +} + +/** + * Posts a new mod request to the server + * @param data Form data + * @returns Numerical ID of the new post + */ +export async function postModRequest(data: ModRequest): Promise { + return fetch(`${addr}/mod-request`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + credentials: 'include', + body: JSON.stringify(data) + }).then((res) => { + if (res.ok) { + return res.text().then((id) => Number(id)); + } else { + throw new Error("Failed to post mod request"); + } + }); +} + +/** + * Retrieve a single mod request by its discussion post ID + * @param id numeric post id + */ +export async function getModRequest(id: number): Promise> { + const res = await fetch(`${addr}/mod-request/${id}`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'include', + }); + if (!res.ok) { + throw new Error(`Failed to fetch mod request ${id}`); + } + return res.json(); +} \ No newline at end of file diff --git a/ui/src/components/Navigation/Navbar.vue b/ui/src/components/Navigation/Navbar.vue index 8c83549..2932399 100644 --- a/ui/src/components/Navigation/Navbar.vue +++ b/ui/src/components/Navigation/Navbar.vue @@ -1,46 +1,46 @@