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: [],
+ });
+ }
+}