Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 120 additions & 0 deletions components/copurchase/Copurchase.module.css
Original file line number Diff line number Diff line change
@@ -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);
}
48 changes: 48 additions & 0 deletions components/copurchase/CopurchaseInvite.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className={styles.modal}>
<button className={styles.closeBtn} aria-label="Close" onClick={onClose}>
×
</button>

<div className={styles.header}>
<div className={styles.title}>Invite to Co-purchase</div>
<div className={styles.subtitle}>
Invite other labs or colleagues to split the cost of this item.
</div>
</div>

<input
className={styles.input}
type="email"
value={email}
placeholder="colleague@ucsd.edu"
onChange={(e) => setEmail(e.target.value)}
/>

<div className={styles.actions}>
<button className={styles.btnSecondary} onClick={onClose}>
Cancel
</button>
<button className={styles.btnPrimary} onClick={handleSend}>
Send Invites
</button>
</div>
</div>
);
}
45 changes: 45 additions & 0 deletions components/copurchase/CopurchaseLabInput.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className={styles.modal}>
<button className={styles.closeBtn} aria-label="Close" onClick={onClose}>
×
</button>

<div className={styles.header}>
<div className={styles.title}>Enter your lab</div>
<div className={styles.subtitle}>
Enter the name of your desired lab
</div>
</div>

<input
className={styles.input}
type="email"
value={email}
placeholder="Dr. Xu"
onChange={(e) => setEmail(e.target.value)}
/>

<div className={styles.actions}>
<button className={styles.btnPrimary} onClick={handleSend}>
Enter
</button>
</div>
</div>
);
}
26 changes: 26 additions & 0 deletions models/Notification.ts
Original file line number Diff line number Diff line change
@@ -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: [
Comment thread
arnavjk007 marked this conversation as resolved.
{
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;
32 changes: 32 additions & 0 deletions models/__tests__/Notification.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
})
55 changes: 55 additions & 0 deletions services/__tests__/Notification.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
4 changes: 4 additions & 0 deletions services/notifications/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { startNotificationWatcher } from "./watchUpdates";

// Entry point for notification service
startNotificationWatcher();
37 changes: 37 additions & 0 deletions services/notifications/watchUpdates.ts
Original file line number Diff line number Diff line change
@@ -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;

Comment thread
arnavjk007 marked this conversation as resolved.
// 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: [],
});
}
}
Loading