Added pretty much everything except discussion forums
This commit is contained in:
53
api/migrations/20260222232949-discussion-posts.js
Normal file
53
api/migrations/20260222232949-discussion-posts.js
Normal file
@@ -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
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
/* Replace with your SQL commands */
|
||||
DROP TABLE discussion_posts;
|
||||
DROP TABLE discussion_comments;
|
||||
34
api/migrations/sqls/20260222232949-discussion-posts-up.sql
Normal file
34
api/migrations/sqls/20260222232949-discussion-posts-up.sql
Normal file
@@ -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);
|
||||
@@ -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) => {
|
||||
|
||||
65
api/src/routes/modRequest.ts
Normal file
65
api/src/routes/modRequest.ts
Normal file
@@ -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<ModRequest>('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<ModRequest>(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<ModRequest>('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;
|
||||
120
api/src/services/db/discussionService.ts
Normal file
120
api/src/services/db/discussionService.ts
Normal file
@@ -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<PagedData<DiscussionPost<T>>>} A promise that resolves to paginated discussion posts with metadata
|
||||
* @throws {Error} If the database query fails
|
||||
*/
|
||||
export async function getAllDiscussions<T>(type?: string, page = 1, pageSize = 10, search?: string): Promise<PagedData<DiscussionPost<T>>> {
|
||||
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<T>[] = await pool.query(sql, params) as DiscussionPost<T>[];
|
||||
|
||||
// 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<Number>} A promise that resolves to the ID of the newly created post
|
||||
* @throws {Error} If the database insertion fails
|
||||
*/
|
||||
export async function createDiscussion<T>(type: string, authorID: number, postTitle: string, data: T): Promise<number> {
|
||||
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<DiscussionPost<T> | null>} The discussion post or null if not found
|
||||
*/
|
||||
export async function getDiscussionById<T>(id: number): Promise<DiscussionPost<T> | 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<T>[];
|
||||
if (results.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return results[0];
|
||||
}
|
||||
|
||||
export async function postComment() {
|
||||
|
||||
}
|
||||
@@ -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();
|
||||
Reference in New Issue
Block a user