From 5cdbf72328ecd2ec7b1530f41b06d4d6a7c0d867 Mon Sep 17 00:00:00 2001 From: ajdj100 Date: Sun, 1 Mar 2026 11:00:38 -0500 Subject: [PATCH] Added pretty much everything except discussion forums --- .../20260222232949-discussion-posts.js | 53 ++++ .../20260222232949-discussion-posts-down.sql | 3 + .../20260222232949-discussion-posts-up.sql | 34 +++ api/src/index.ts | 2 + api/src/routes/modRequest.ts | 65 +++++ api/src/services/db/discussionService.ts | 120 +++++++++ api/src/services/logging/auditLog.ts | 6 +- shared/schemas/modRequest.ts | 24 ++ shared/types/discussion.ts | 24 ++ ui/src/api/modRequests.ts | 72 ++++++ .../components/modRequests/ModRequestForm.vue | 241 ++++++++++++++++++ .../components/modRequests/ModRequestList.vue | 125 +++++++++ .../components/modRequests/ViewModRequest.vue | 107 ++++++++ ui/src/pages/ModRequest.vue | 32 +++ ui/src/router/index.ts | 10 + 15 files changed, 917 insertions(+), 1 deletion(-) create mode 100644 api/migrations/20260222232949-discussion-posts.js create mode 100644 api/migrations/sqls/20260222232949-discussion-posts-down.sql create mode 100644 api/migrations/sqls/20260222232949-discussion-posts-up.sql create mode 100644 api/src/routes/modRequest.ts create mode 100644 api/src/services/db/discussionService.ts create mode 100644 shared/schemas/modRequest.ts create mode 100644 shared/types/discussion.ts create mode 100644 ui/src/api/modRequests.ts create mode 100644 ui/src/components/modRequests/ModRequestForm.vue create mode 100644 ui/src/components/modRequests/ModRequestList.vue create mode 100644 ui/src/components/modRequests/ViewModRequest.vue create mode 100644 ui/src/pages/ModRequest.vue 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..acc01e1 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -104,6 +104,7 @@ 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' app.use('/application', applicationRouter); app.use('/ranks', ranks); @@ -119,6 +120,7 @@ app.use('/courseEvent', eventRouter) app.use('/calendar', calendarRouter) app.use('/units', units) app.use('/docs', docsRouter) +app.use('/mod-request', modRequestRouter) app.use('/', authRouter) app.get('/ping', (req, res) => { 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..4b3cc37 --- /dev/null +++ b/api/src/services/db/discussionService.ts @@ -0,0 +1,120 @@ + + +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'; + +/** + * 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> { + const sql = ` + 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 results = (await pool.query(sql, [id])) as DiscussionPost[]; + if (results.length === 0) { + return null; + } + return results[0]; +} + +export async function postComment() { + +} \ No newline at end of file diff --git a/api/src/services/logging/auditLog.ts b/api/src/services/logging/auditLog.ts index fded7ca..d70109a 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', 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..62d6370 --- /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/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/modRequests/ModRequestForm.vue b/ui/src/components/modRequests/ModRequestForm.vue new file mode 100644 index 0000000..d186de5 --- /dev/null +++ b/ui/src/components/modRequests/ModRequestForm.vue @@ -0,0 +1,241 @@ + + +