Skip to content

Commit ad47495

Browse files
authored
Merge pull request #73 from CS3219-AY2425S1/n2-user-question-history
N2 user question history
2 parents 2a52d5e + e1733f1 commit ad47495

File tree

24 files changed

+803
-167
lines changed

24 files changed

+803
-167
lines changed

backend/api-gateway/nginx.conf

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,19 @@ http {
6363
proxy_send_timeout 60s;
6464
}
6565

66+
# Proxy for /api/categories to service running on port 3002
67+
location /api/history/ {
68+
proxy_pass http://host.docker.internal:3002/api/history/;
69+
proxy_set_header Host $host;
70+
proxy_set_header X-Real-IP $remote_addr;
71+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
72+
proxy_set_header X-Forwarded-Proto $scheme;
73+
74+
proxy_connect_timeout 60s;
75+
proxy_read_timeout 60s;
76+
proxy_send_timeout 60s;
77+
}
78+
6679
# Proxy for /api/match to service running on port 3003
6780
location /api/match/ {
6881
proxy_pass http://host.docker.internal:3003/api/match/;

backend/matching-service/controllers/matchingController.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,7 @@ async function handleMatchEvent(
306306
message: `You have been matched with User ID: ${user2.userId}`,
307307
category: user1.category || user2.category || "Any",
308308
difficulty: user1.difficulty || user2.difficulty || "Any",
309+
attemptStartedAt: Date.now(),
309310
matchId: matchId,
310311
roomId: roomId,
311312
matchUserId: user2.userId,
@@ -316,6 +317,7 @@ async function handleMatchEvent(
316317
message: `You have been matched with User ID: ${user1.userId}`,
317318
category: user2.category || user1.category || "Any",
318319
difficulty: user2.difficulty || user1.difficulty || "Any",
320+
attemptStartedAt: Date.now(),
319321
matchId: matchId,
320322
roomId: roomId,
321323
matchUserId: user1.userId,

backend/question-service/.env.example

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ PORT=3002
77
# Initialize data from questions.json
88
POPULATE_DB=true
99

10+
JWT_ACCESS_TOKEN_SECRET=<your-jwt-accecss-token>
11+
1012
# Kafka configuration
1113
KAFKA_HOST=localhost
1214
KAFKA_PORT=9092
@@ -15,8 +17,6 @@ KAFKA_PORT=9092
1517
# If using mongoDB containerization, set to true. Else set to false (i.e local testing)
1618
DB_REQUIRE_AUTH=true
1719

18-
JWT_ACCESS_TOKEN_SECRET=<your-jwt-accecss-token>
19-
2020
# mongoDB auth variables
2121
MONGO_USER=user
2222
MONGO_PASSWORD=password

backend/question-service/app.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import categoriesRoutes from './routes/categoriesRoutes';
77
import { connectToDatabase } from './utils/database';
88
import { errorHandler } from './middlewares/errorHandler';
99
import { populateQuestions } from './utils/populateQuestions';
10+
import historyRoutes from './routes/historyRoutes';
1011
import { setUpKafkaSubscribers } from './utils/kafkaClient';
1112

1213
dotenv.config({ path: path.resolve(__dirname, './.env') });
@@ -25,6 +26,7 @@ app.use(cors({
2526

2627
app.use('/api/questions', questionsRoutes);
2728
app.use('/api/categories', categoriesRoutes);
29+
app.use('/api/history', historyRoutes)
2830

2931
app.use(errorHandler);
3032

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import { Response } from 'express';
2+
import jwt, { JwtPayload } from 'jsonwebtoken';
3+
import historyEntryModel from '../models/HistoryEntry';
4+
import { AuthenticatedRequest } from 'middlewares/auth';
5+
6+
const getErrorMessage = (error: unknown): string => {
7+
if (error instanceof Error) return error.message;
8+
return 'An unexpected error occurred';
9+
};
10+
11+
const extractUserIdFromToken = (req: AuthenticatedRequest): string | null => {
12+
const userId = req.userId;
13+
if (!userId) {
14+
console.error('userId missing - Token is likely invalid');
15+
return null;
16+
}
17+
return userId
18+
};
19+
20+
export const getUserHistoryEntries = async (req: AuthenticatedRequest, res: Response) => {
21+
try {
22+
const userId = extractUserIdFromToken(req);
23+
24+
if (!userId) {
25+
return res.status(401).json({ error: 'Invalid or missing token' });
26+
}
27+
28+
const historyEntries = await historyEntryModel.find({ userId })
29+
.populate({
30+
path: 'question',
31+
populate: {
32+
path: 'categories',
33+
model: 'category',
34+
},
35+
});
36+
const historyViewModels = historyEntries.map((entry) => {
37+
return {
38+
id: entry._id,
39+
key: entry._id,
40+
attemptStartedAt: entry.attemptStartedAt.getTime(),
41+
attemptCompletedAt: entry.attemptCompletedAt.getTime(),
42+
title: entry.question.title,
43+
difficulty: entry.question.difficulty,
44+
topics: entry.question.categories.map((cat: any) => cat.name),
45+
attemptCode: entry.attemptCode,
46+
}});
47+
res.status(200).json(historyViewModels);
48+
} catch (error) {
49+
res.status(500).json({ error: getErrorMessage(error) });
50+
}
51+
};
52+
53+
export const createOrUpdateUserHistoryEntry = async (req: AuthenticatedRequest, res: Response) => {
54+
try {
55+
const userId = extractUserIdFromToken(req);
56+
57+
if (!userId) {
58+
return res.status(401).json({ error: 'Invalid or missing token' });
59+
}
60+
61+
const { questionId, roomId, attemptStartedAt, attemptCompletedAt, collaboratorId, attemptCode } = req.body;
62+
63+
if (!roomId) {
64+
return res.status(400).json({ error: 'roomId is required' });
65+
}
66+
67+
const existingEntry = await historyEntryModel.findOne({ userId, roomId });
68+
69+
if (existingEntry) {
70+
existingEntry.question = questionId;
71+
existingEntry.attemptStartedAt = attemptStartedAt;
72+
existingEntry.attemptCompletedAt = attemptCompletedAt;
73+
existingEntry.collaboratorId = collaboratorId;
74+
existingEntry.attemptCode = attemptCode;
75+
76+
const updatedEntry = await existingEntry.save();
77+
78+
return res.status(200).json(updatedEntry);
79+
} else {
80+
const newHistoryEntry = new historyEntryModel({
81+
userId,
82+
question: questionId,
83+
roomId,
84+
attemptStartedAt,
85+
attemptCompletedAt,
86+
collaboratorId,
87+
attemptCode,
88+
});
89+
90+
const savedEntry = await newHistoryEntry.save();
91+
92+
return res.status(201).json(savedEntry);
93+
}
94+
} catch (error) {
95+
return res.status(500).json({ error: getErrorMessage(error) });
96+
}
97+
};
98+
99+
export const deleteUserHistoryEntry = async (req: AuthenticatedRequest, res: Response) => {
100+
try {
101+
const userId = extractUserIdFromToken(req);
102+
103+
if (!userId) {
104+
return res.status(401).json({ error: 'Invalid or missing token' });
105+
}
106+
107+
const { id } = req.params;
108+
109+
const deletedEntry = await historyEntryModel.findOneAndDelete({ _id: id, userId });
110+
111+
if (!deletedEntry) {
112+
return res.status(404).json({ message: 'History entry not found' });
113+
}
114+
115+
res.status(200).json({ message: 'History entry deleted successfully' });
116+
} catch (error) {
117+
res.status(500).json({ error: getErrorMessage(error) });
118+
}
119+
};
120+
121+
export const deleteUserHistoryEntries = async (req: AuthenticatedRequest, res: Response) => {
122+
try {
123+
const userId = extractUserIdFromToken(req);
124+
125+
if (!userId) {
126+
return res.status(401).json({ error: 'Invalid or missing token' });
127+
}
128+
129+
const { ids } = req.body;
130+
if (!Array.isArray(ids)) {
131+
return res.status(400).json({ message: '"ids" must be an array of history entry IDs.' });
132+
}
133+
134+
const result = await historyEntryModel.deleteMany({ _id: { $in: ids }, userId });
135+
res.status(200).json({ message: `${result.deletedCount} history entries deleted.` });
136+
} catch (error) {
137+
res.status(500).json({ error: getErrorMessage(error) });
138+
}
139+
};
140+
141+
export const deleteAllUserHistoryEntries = async (req: AuthenticatedRequest, res: Response) => {
142+
try {
143+
const userId = extractUserIdFromToken(req);
144+
145+
if (!userId) {
146+
return res.status(401).json({ error: 'Invalid or missing token' });
147+
}
148+
149+
const result = await historyEntryModel.deleteMany({ userId });
150+
res.status(200).json({
151+
message: `${result.deletedCount} history entries deleted for user ${userId}.`,
152+
});
153+
} catch (error) {
154+
res.status(500).json({ error: getErrorMessage(error) });
155+
}
156+
};
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { Request, Response, NextFunction } from 'express';
2+
import jwt from 'jsonwebtoken';
3+
4+
export interface AuthenticatedRequest extends Request {
5+
userId?: string;
6+
}
7+
8+
export const authenticateToken = (req: AuthenticatedRequest, res: Response, next: NextFunction) => {
9+
const authHeader = req.headers['authorization'];
10+
11+
if (!authHeader) return res.status(401).json({ error: 'Authorization header missing' });
12+
13+
const token = authHeader.split(' ')[1];
14+
15+
if (!token) return res.status(401).json({ error: 'Token missing' });
16+
17+
jwt.verify(token, process.env.JWT_ACCESS_TOKEN_SECRET as string, (err, decoded: any) => {
18+
if (err) return res.status(403).json({ error: 'Invalid token' });
19+
20+
req.userId = decoded.id;
21+
next();
22+
});
23+
};
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import mongoose, { Schema, Types } from 'mongoose';
2+
import { Question } from './Question';
3+
4+
export interface HistoryEntry extends mongoose.Document {
5+
_id: Types.ObjectId;
6+
userId: string;
7+
question: Question;
8+
roomId: string; // Note: This should not be used to retrieve the room from matching service! This is only here to serve as a uniqueness check for updating attempt information!
9+
attemptStartedAt: Date;
10+
attemptCompletedAt: Date;
11+
collaboratorId: string;
12+
attemptCode: string;
13+
}
14+
15+
const historyEntrySchema: Schema = new Schema<HistoryEntry>({
16+
userId: { type: String, required: true },
17+
question: { type: Schema.Types.ObjectId, ref: 'question', required: true },
18+
roomId: { type: String, required: true, unique: false },
19+
attemptStartedAt: { type: Date, required: true, default: Date.now() },
20+
attemptCompletedAt: { type: Date, required: true, default: Date.now() },
21+
collaboratorId: { type: String, required: true },
22+
attemptCode: { type: String, required: true },
23+
});
24+
25+
const historyEntryModel = mongoose.model<HistoryEntry>('historyEntry', historyEntrySchema);
26+
export default historyEntryModel;
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import express from 'express';
2+
import {
3+
getUserHistoryEntries,
4+
createOrUpdateUserHistoryEntry,
5+
deleteUserHistoryEntry,
6+
deleteUserHistoryEntries,
7+
deleteAllUserHistoryEntries,
8+
} from '../controllers/historyController';
9+
import { authenticateToken } from '../middlewares/auth';
10+
11+
const router = express.Router();
12+
13+
router.get("/", authenticateToken, getUserHistoryEntries);
14+
router.post("/", authenticateToken, createOrUpdateUserHistoryEntry);
15+
router.delete("/user/:id", authenticateToken, deleteUserHistoryEntry);
16+
router.delete("/user", authenticateToken, deleteUserHistoryEntries);
17+
router.delete("/all", authenticateToken, deleteAllUserHistoryEntries);
18+
19+
export default router;
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
2+
import { HistoryEntry } from "domain/entities/HistoryEntry";
3+
import { BaseApi } from "../../infrastructure/Api/BaseApi";
4+
5+
const API_URL = "/api/history";
6+
7+
export class HistoryRemoteDataSource extends BaseApi {
8+
constructor() {
9+
super(API_URL);
10+
}
11+
12+
async getAllUserHistories(): Promise<HistoryEntry[]> {
13+
return await this.protectedGet<HistoryEntry[]>("/");
14+
}
15+
16+
async createOrUpdateUserHistory(questionId: string, roomId: string, attemptStartedAt: string, attemptCompletedAt: string, collaboratorId: string, attemptCode: string): Promise<void> {
17+
return await this.protectedPost<void>("/", { questionId, roomId, attemptStartedAt, attemptCompletedAt, collaboratorId, attemptCode });
18+
}
19+
20+
async deleteSelectedHistories(selectedHistoryIds: string[]): Promise<void> {
21+
console.log("Delete")
22+
selectedHistoryIds.forEach(async (id) => {
23+
console.log(id);
24+
await this.protectedDelete<void>(`/user/${id}`);
25+
});
26+
}
27+
28+
async deleteAllUserHistories(): Promise<void> {
29+
await this.protectedDelete<void>("/all");
30+
}
31+
}
32+
33+
export const historyRemoteDataSource = new HistoryRemoteDataSource();
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { HistoryEntry } from "domain/entities/HistoryEntry";
2+
import { historyRemoteDataSource } from "./HistoryRemoteDataSource";
3+
4+
export class HistoryRepositoryImpl {
5+
private dataSource = historyRemoteDataSource;
6+
7+
async getAllUserHistories(): Promise<HistoryEntry[]> {
8+
return this.dataSource.getAllUserHistories();
9+
}
10+
11+
async createOrUpdateUserHistory(questionId: string, roomId: string, attemptStartedAt: string, attemptCompletedAt: string, collaboratorId: string, attemptCode: string): Promise<void> {
12+
this.dataSource.createOrUpdateUserHistory(questionId, roomId, attemptStartedAt, attemptCompletedAt, collaboratorId, attemptCode)
13+
}
14+
15+
async deleteSelectedUserHistories(selectedHistoryIds: string[]): Promise<void> {
16+
this.dataSource.deleteSelectedHistories(selectedHistoryIds);
17+
}
18+
19+
async deleteAllUserHistories(): Promise<void> {
20+
this.dataSource.deleteAllUserHistories();
21+
}
22+
}
23+
24+
export const historyRepository = new HistoryRepositoryImpl();

0 commit comments

Comments
 (0)