diff --git a/components/copurchase/Copurchase.module.css b/components/copurchase/Copurchase.module.css new file mode 100644 index 0000000..28a41af --- /dev/null +++ b/components/copurchase/Copurchase.module.css @@ -0,0 +1,120 @@ +.modal { + background: #ffffff; + width: 456px; + border-radius: 10px; + border: 1px solid #d8d8d8; + box-shadow: 0 4px 24px rgba(0, 0, 0, 0.08); + padding: 25px; + position: relative; +} + +.closeBtn { + position: absolute; + top: 17px; + right: 17px; + width: 16px; + height: 16px; + border-radius: 2px; + border: none; + background: transparent; + cursor: pointer; + opacity: 0.7; + display: flex; + align-items: center; + justify-content: center; + padding: 0; + transition: opacity 0.15s; +} + +.closeBtn:hover { + opacity: 1; +} + +.header { + display: flex; + flex-direction: column; + gap: 8px; + margin-bottom: 20px; + padding-right: 24px; +} + +.title { + font-size: 17px; + font-weight: 700; + color: #1a1a1a; + line-height: 1.2; +} + +.subtitle { + font-size: 13px; + color: #666; + line-height: 1.4; +} + +.input { + width: 100%; + height: 36px; + border-radius: 8px; + border: 1px solid #d8d8d8; + padding: 4px 12px; + font-size: 13px; + color: #444; + background: #fafafa; + outline: none; + margin-bottom: 14px; + box-sizing: border-box; + transition: border-color 0.15s, box-shadow 0.15s; +} + +.input:focus { + border-color: #2e6b8a; + box-shadow: 0 0 0 3px rgba(46, 107, 138, 0.12); +} + +.actions { + display: flex; + justify-content: flex-end; + gap: 8px; +} + +.btnPrimary { + height: 36px; + border-radius: 8px; + border: none; + cursor: pointer; + font-size: 13px; + font-weight: 600; + padding: 0 20px; + background: #2e6b8a; + color: #fff; + transition: opacity 0.15s, transform 0.1s; +} + +.btnPrimary:hover { + opacity: 0.88; +} + +.btnPrimary:active { + transform: scale(0.97); +} + +.btnSecondary { + height: 36px; + border-radius: 8px; + border: none; + cursor: pointer; + font-size: 13px; + font-weight: 600; + padding: 0 20px; + background: #f0f0f0; + color: #444; + transition: opacity 0.15s, transform 0.1s; +} + +.btnSecondary:hover { + opacity: 0.75; +} + +.btnSecondary:active { + transform: scale(0.97); +} \ No newline at end of file diff --git a/components/copurchase/CopurchaseInvite.tsx b/components/copurchase/CopurchaseInvite.tsx new file mode 100644 index 0000000..c996e70 --- /dev/null +++ b/components/copurchase/CopurchaseInvite.tsx @@ -0,0 +1,48 @@ +import { useState } from "react"; +import styles from "./CoPurchase.module.css"; + +interface InviteModalProps { + onClose: () => void; + onSend: (email: string) => void; +} + +export default function CopurchaseInvite({ onClose, onSend }: InviteModalProps) { + const [email, setEmail] = useState(""); + + const handleSend = () => { + onSend(email); + onClose(); + }; + + return ( +
+ + +
+
Invite to Co-purchase
+
+ Invite other labs or colleagues to split the cost of this item. +
+
+ + setEmail(e.target.value)} + /> + +
+ + +
+
+ ); +} \ No newline at end of file diff --git a/components/copurchase/CopurchaseLabInput.tsx b/components/copurchase/CopurchaseLabInput.tsx new file mode 100644 index 0000000..a98b39a --- /dev/null +++ b/components/copurchase/CopurchaseLabInput.tsx @@ -0,0 +1,45 @@ +import { useState } from "react"; +import styles from "./CoPurchase.module.css"; + +interface InviteModalProps { + onClose: () => void; + onSend: (email: string) => void; +} + +export default function CopurchaseInvite({ onClose, onSend }: InviteModalProps) { + const [email, setEmail] = useState(""); + + const handleSend = () => { + onSend(email); + onClose(); + }; + + return ( +
+ + +
+
Enter your lab
+
+ Enter the name of your desired lab +
+
+ + setEmail(e.target.value)} + /> + +
+ +
+
+ ); +} \ No newline at end of file diff --git a/models/Notification.ts b/models/Notification.ts new file mode 100644 index 0000000..98f0992 --- /dev/null +++ b/models/Notification.ts @@ -0,0 +1,26 @@ +import mongoose from 'mongoose'; +const { Schema } = mongoose; + +const notificationSchema = new Schema({ + labId: { type: String, required: true }, + type: { type: String, required: true }, + resourceId: { type: String, required: true }, + recipients: [ + { + role: [{ + type: String, + required: true, + enum: ["PI", "LAB_MANAGER", "RESEARCHER"], + }], + channel: [{ + type: String, + }], + } + ], + createdAt: { type: Date, required: true, default: Date.now } +}); + +const Notification = mongoose.models.Notification || + mongoose.model('Notification', notificationSchema); + +export default Notification; \ No newline at end of file diff --git a/models/__tests__/Notification.test.ts b/models/__tests__/Notification.test.ts new file mode 100644 index 0000000..2addc92 --- /dev/null +++ b/models/__tests__/Notification.test.ts @@ -0,0 +1,32 @@ +import Notification from "../Notification"; + + +describe("Notification Schema", () => { + test("valid user passes", async () => { + const notification = new Notification({ + labId: "lab123", + type: "DB_UPDATE", + resourceId: "item123", + recipients: [ + { + role: ["PI", "RESEARCHER"], + channel: ["EMAIL"], + }, + ], + }); + await expect(notification.validate()).resolves.toBeUndefined(); + }); + + test("missing required field fails", async () => { + const notification = new Notification({ + type: "DB_UPDATE", + recipients: [ + { + role: ["PI", "RESEARCHER"], + channel: ["EMAIL"], + }, + ], + }); + await expect(notification.validate()).rejects.toThrow(); + }); +}) \ No newline at end of file diff --git a/services/__tests__/Notification.test.ts b/services/__tests__/Notification.test.ts new file mode 100644 index 0000000..365c88c --- /dev/null +++ b/services/__tests__/Notification.test.ts @@ -0,0 +1,55 @@ +import mongoose from "mongoose"; +import { startNotificationWatcher } from "../notifications/watchUpdates"; +import Notification from "@/models/Notification"; + +jest.mock("mongoose", () => ({ + ...jest.requireActual("mongoose"), + connection: { + collection: jest.fn(), + }, +})); + +jest.mock("@/models/Notification", () => ({ + __esModule: true, + default: { + create: jest.fn(), + }, +})); + +jest.mock("../../lib/mongoose", () => ({ + connectToDatabase: jest.fn(), +})); + +const mockCollection = { + watch: jest.fn(), +}; + +beforeEach(() => { + (mongoose.connection.collection as jest.Mock).mockReturnValue(mockCollection); +}); + +afterEach(() => { + jest.clearAllMocks(); +}); + +describe("startNotificationWatcher", () => { + test("creates notification on update event", async () => { + const fakeStream = { + async *[Symbol.asyncIterator]() { + yield { + operationType: "update", + fullDocument: { + _id: "item1", + labId: "lab1", + }, + }; + }, + }; + + mockCollection.watch.mockReturnValue(fakeStream); + + await startNotificationWatcher(); + + expect(Notification.create).toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/services/notifications/index.ts b/services/notifications/index.ts new file mode 100644 index 0000000..55ec001 --- /dev/null +++ b/services/notifications/index.ts @@ -0,0 +1,4 @@ +import { startNotificationWatcher } from "./watchUpdates"; + +// Entry point for notification service +startNotificationWatcher(); diff --git a/services/notifications/watchUpdates.ts b/services/notifications/watchUpdates.ts new file mode 100644 index 0000000..a1fa716 --- /dev/null +++ b/services/notifications/watchUpdates.ts @@ -0,0 +1,37 @@ +import mongoose from "mongoose"; +import { connectToDatabase } from "@/lib/mongoose"; +import Notification from "@/models/Notification"; + +/** + * Starts a Change Stream watcher on the "products" collection. + * Inserts a DB_UPDATE notification whenever a product is updated. + */ +export async function startNotificationWatcher() { + await connectToDatabase(); + + const collection = mongoose.connection.collection("items"); + + const changeStream = collection.watch([], { + fullDocument: "updateLookup", + }); + + for await (const change of changeStream) { + if (change.operationType !== "update") continue; + + // Get the updated document from the change stream + const updatedDoc = change.fullDocument; + + // If there’s no document, skip this iteration + if (!updatedDoc) continue; + + // TODO: Check the quantity in updatedDoc + // If quantity is below the threshold, continue + // Otherwise, send a notification + await Notification.create({ + type: "DB_UPDATE", + labId: updatedDoc.labId ?? "unknown", + resourceId: String(updatedDoc._id), + recipients: [], + }); + } +}