Skip to content

Commit b144e41

Browse files
committed
Implement admin module editing locking mechanism
1 parent a36d1cf commit b144e41

File tree

4 files changed

+382
-22
lines changed

4 files changed

+382
-22
lines changed

backend/server.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
registerNotificationHandlers,
1717
registerNotificationSchemaListener,
1818
} from "./sockets/notification";
19+
import { registerModuleEditingHandlers } from "./sockets/moduleEditing";
1920
import helpRequestRouter from "./rest/helpRequestRoutes";
2021
import notificationRouter from "./rest/notificationRoutes";
2122
import courseRouter from "./rest/courseRoutes";
@@ -64,6 +65,7 @@ io.on("connection", (socket) => {
6465
if (!userId && typeof userId !== "string") return;
6566
socket.join(userId);
6667
registerNotificationHandlers(io, socket);
68+
registerModuleEditingHandlers(io, socket);
6769
});
6870

6971
registerNotificationSchemaListener(io);

backend/sockets/moduleEditing.ts

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import { Server, Socket } from "socket.io";
2+
import logger from "../utilities/logger";
3+
4+
const Logger = logger(__filename);
5+
6+
// Store active module editing locks: moduleId -> { userId, userName, socketId }
7+
const moduleEditingLocks = new Map<
8+
string,
9+
{ userId: string; userName: string; socketId: string }
10+
>();
11+
12+
interface AcquireLockPayload {
13+
moduleId: string;
14+
userId: string;
15+
userName: string;
16+
}
17+
18+
interface ReleaseLockPayload {
19+
moduleId: string;
20+
userId: string;
21+
}
22+
23+
const registerModuleEditingHandlers = (io: Server, socket: Socket) => {
24+
// Attempt to acquire editing lock for a module
25+
const acquireLock = (payload: AcquireLockPayload) => {
26+
const { moduleId, userId, userName } = payload;
27+
28+
try {
29+
const existingLock = moduleEditingLocks.get(moduleId);
30+
31+
// Check if module is already locked by someone else
32+
if (existingLock && existingLock.userId !== userId) {
33+
Logger.info(
34+
`Module ${moduleId} is already locked by user ${existingLock.userName}`,
35+
);
36+
socket.emit("moduleEditing:lockDenied", {
37+
moduleId,
38+
currentEditor: {
39+
userId: existingLock.userId,
40+
userName: existingLock.userName,
41+
},
42+
});
43+
return;
44+
}
45+
46+
// Acquire the lock
47+
moduleEditingLocks.set(moduleId, {
48+
userId,
49+
userName,
50+
socketId: socket.id,
51+
});
52+
53+
Logger.info(`User ${userName} acquired lock for module ${moduleId}`);
54+
socket.emit("moduleEditing:lockAcquired", { moduleId });
55+
} catch (error) {
56+
Logger.error(`Failed to acquire lock for module ${moduleId}: ${error}`);
57+
socket.emit("moduleEditing:error", {
58+
moduleId,
59+
message: "Failed to acquire editing lock",
60+
});
61+
}
62+
};
63+
64+
// Release editing lock for a module
65+
const releaseLock = (payload: ReleaseLockPayload) => {
66+
const { moduleId, userId } = payload;
67+
68+
try {
69+
const existingLock = moduleEditingLocks.get(moduleId);
70+
71+
// Only the lock owner can release it
72+
if (existingLock && existingLock.userId === userId) {
73+
moduleEditingLocks.delete(moduleId);
74+
Logger.info(`User ${userId} released lock for module ${moduleId}`);
75+
socket.emit("moduleEditing:lockReleased", { moduleId });
76+
} else {
77+
Logger.warn(
78+
`User ${userId} attempted to release lock for module ${moduleId} but doesn't own it`,
79+
);
80+
}
81+
} catch (error) {
82+
Logger.error(`Failed to release lock for module ${moduleId}: ${error}`);
83+
}
84+
};
85+
86+
// Clean up locks when socket disconnects
87+
const handleDisconnect = () => {
88+
try {
89+
// Find and remove all locks held by this socket
90+
const locksToRemove: string[] = [];
91+
moduleEditingLocks.forEach((lock, moduleId) => {
92+
if (lock.socketId === socket.id) {
93+
locksToRemove.push(moduleId);
94+
}
95+
});
96+
97+
locksToRemove.forEach((moduleId) => {
98+
moduleEditingLocks.delete(moduleId);
99+
Logger.info(
100+
`Released lock for module ${moduleId} due to socket disconnect`,
101+
);
102+
});
103+
} catch (error) {
104+
Logger.error(`Failed to clean up locks on disconnect: ${error}`);
105+
}
106+
};
107+
108+
socket.on("moduleEditing:acquireLock", acquireLock);
109+
socket.on("moduleEditing:releaseLock", releaseLock);
110+
socket.on("disconnect", handleDisconnect);
111+
};
112+
113+
const removeModuleEditingHandlers = (socket: Socket) => {
114+
socket.removeAllListeners("moduleEditing:acquireLock");
115+
socket.removeAllListeners("moduleEditing:releaseLock");
116+
};
117+
118+
export {
119+
registerModuleEditingHandlers,
120+
removeModuleEditingHandlers,
121+
moduleEditingLocks,
122+
};
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
import CloseIcon from "@mui/icons-material/Close";
2+
import LockIcon from "@mui/icons-material/Lock";
3+
import {
4+
Box,
5+
Button,
6+
IconButton,
7+
Modal,
8+
Stack,
9+
Typography,
10+
useTheme,
11+
} from "@mui/material";
12+
import * as Routes from "../../constants/Routes";
13+
14+
interface ModuleLockedModalProps {
15+
open: boolean;
16+
onClose: () => void;
17+
editorName: string;
18+
unitId?: string;
19+
}
20+
21+
const ModuleLockedModal = ({
22+
open,
23+
onClose,
24+
editorName,
25+
unitId,
26+
}: ModuleLockedModalProps) => {
27+
const theme = useTheme();
28+
29+
const handleBackToCourse = () => {
30+
if (unitId) {
31+
window.location.href = `${Routes.COURSE_PAGE}?unitId=${unitId}`;
32+
} else {
33+
window.location.href = Routes.COURSE_PAGE;
34+
}
35+
onClose();
36+
};
37+
38+
const commonButtonStyles = {
39+
display: "flex",
40+
height: "40px",
41+
flexDirection: "row",
42+
justifyContent: "center",
43+
alignItems: "center",
44+
borderRadius: "4px",
45+
textTransform: "none",
46+
font: theme.typography.labelMedium,
47+
lineHeight: "normal",
48+
} as const;
49+
50+
return (
51+
<Modal
52+
open={open}
53+
onClose={onClose}
54+
aria-labelledby="module-locked-title"
55+
aria-describedby="module-locked-description"
56+
>
57+
<Box
58+
sx={{
59+
position: "absolute",
60+
top: "50%",
61+
left: "50%",
62+
transform: "translate(-50%, -50%)",
63+
display: "flex",
64+
width: "450px",
65+
padding: "32px",
66+
flexDirection: "column",
67+
justifyContent: "flex-end",
68+
alignItems: "flex-start",
69+
gap: "32px",
70+
borderRadius: "8px",
71+
border: "1px solid",
72+
borderColor: theme.palette.Neutral[400],
73+
background: "#FFF",
74+
boxShadow: 24,
75+
}}
76+
>
77+
<IconButton
78+
onClick={onClose}
79+
sx={{
80+
position: "absolute",
81+
top: "8px",
82+
right: "8px",
83+
color: theme.palette.Neutral[700],
84+
}}
85+
aria-label="Close modal"
86+
>
87+
<CloseIcon />
88+
</IconButton>
89+
90+
<Stack direction="column" alignItems="flex-start" gap="16px">
91+
<Box
92+
sx={{
93+
display: "flex",
94+
alignItems: "center",
95+
gap: "12px",
96+
}}
97+
>
98+
<LockIcon
99+
sx={{
100+
color: theme.palette.Warning.Dark.Default,
101+
fontSize: "32px",
102+
}}
103+
/>
104+
<Typography
105+
id="module-locked-title"
106+
variant="headlineMedium"
107+
sx={{ color: theme.palette.Neutral[900] }}
108+
>
109+
Module Currently Being Edited
110+
</Typography>
111+
</Box>
112+
<Typography
113+
id="module-locked-description"
114+
variant="bodyMedium"
115+
sx={{ color: theme.palette.Neutral[700] }}
116+
>
117+
<strong>{editorName}</strong> is currently editing this module. Only
118+
one administrator can edit a module at a time to prevent conflicts.
119+
Please try again later or contact {editorName} directly.
120+
</Typography>
121+
</Stack>
122+
123+
<Stack direction="row" sx={{ alignSelf: "flex-end", gap: "12px" }}>
124+
<Button
125+
variant="outlined"
126+
onClick={onClose}
127+
sx={{
128+
...commonButtonStyles,
129+
color: theme.palette.Neutral[600],
130+
border: `1px solid ${theme.palette.Neutral[400]}`,
131+
"&:hover": {
132+
border: `1px solid ${theme.palette.Neutral[400]}`,
133+
backgroundColor: theme.palette.Neutral[200],
134+
},
135+
}}
136+
>
137+
Close
138+
</Button>
139+
<Button
140+
variant="contained"
141+
onClick={handleBackToCourse}
142+
sx={{
143+
...commonButtonStyles,
144+
color: "#FFF",
145+
backgroundColor: theme.palette.Administrator.Dark.Default,
146+
"&:hover": {
147+
backgroundColor: theme.palette.Administrator.Dark.Hover,
148+
},
149+
}}
150+
>
151+
Back to Course
152+
</Button>
153+
</Stack>
154+
</Box>
155+
</Modal>
156+
);
157+
};
158+
159+
export default ModuleLockedModal;

0 commit comments

Comments
 (0)