diff --git a/backend/controllers/rec.controller.ts b/backend/controllers/rec.controller.ts new file mode 100644 index 00000000..a9f90ac2 --- /dev/null +++ b/backend/controllers/rec.controller.ts @@ -0,0 +1,180 @@ +import { Response, NextFunction } from "express"; +import { RequestExtended } from "../middleware/verifyAuth"; +import prisma from "../prisma/client"; +import cosineSimilarity from "../utils/cosineSimilarity"; +import normalizeTags from "../utils/normalizeTags"; +import { getDistanceFromLatLonInKm } from "../utils/googleMapsApi"; + +export const recommendEvents = async ( + req: RequestExtended, + res: Response, + next: NextFunction, +) => { + // modify during integration + const loggedInUserId = req.body.userId; + + const latitude = req.body.latitude; + const longitude = req.body.longitude; + + const user = await prisma.user.findUnique({ + where: { + id: loggedInUserId, + }, + include: { + tags: true, + }, + }); + const userTags = user?.tags; + + const allTags = await prisma.tag.findMany({}); + + if (userTags) { + const userTagsNormalized = normalizeTags(userTags, allTags); + + const allEvents = await prisma.event.findMany({ + include: { + tags: true, + location: true, + }, + }); + + const eventTagMap = new Map(); + + allEvents.forEach((event) => { + const eventTagsNormalized = normalizeTags(event.tags, allTags); + eventTagMap.set(event.id, eventTagsNormalized); + }); + + const recommendationMap = new Map(); + + for (let [key, value] of eventTagMap) { + let similarity = cosineSimilarity(userTagsNormalized, value); + recommendationMap.set(key, similarity); + } + + const similarityArray = Array.from(recommendationMap.entries()); + similarityArray.sort((a, b) => b[1] - a[1]); + + // sorting similarityArray according to distance + similarityArray.sort((a, b) => { + const eventA = allEvents.find((event) => event.id === a[0]); + const eventB = allEvents.find((event) => event.id === b[0]); + if (eventA && eventB) { + const distanceA = getDistanceFromLatLonInKm( + latitude, + longitude, + eventA.location.latitude, + eventA.location.longitude, + ); + const distanceB = getDistanceFromLatLonInKm( + latitude, + longitude, + eventB.location.latitude, + eventB.location.longitude, + ); + return distanceA - distanceB; + } + return 0; + }); + + res.status(200).json({ similarityArray }); + } +}; + +export const recommendPosts = async ( + req: RequestExtended, + res: Response, + next: NextFunction, +) => { + // modify during integration + const loggedInUserId = req.body.userId; + const user = await prisma.user.findUnique({ + where: { + id: loggedInUserId, + }, + include: { + tags: true, + }, + }); + const userTags = user?.tags; + + const allTags = await prisma.tag.findMany({}); + + if (userTags) { + const userTagsNormalized = normalizeTags(userTags, allTags); + + const allPosts = await prisma.post.findMany({ + include: { + tags: true, + }, + }); + + const postTagMap = new Map(); + + allPosts.forEach((post) => { + const eventTagsNormalized = normalizeTags(post.tags, allTags); + postTagMap.set(post.id, eventTagsNormalized); + }); + + const recommendationMap = new Map(); + + for (let [key, value] of postTagMap) { + let similarity = cosineSimilarity(userTagsNormalized, value); + recommendationMap.set(key, similarity); + } + + const similarityArray = Array.from(recommendationMap.entries()); + similarityArray.sort((a, b) => b[1] - a[1]); + + res.status(200).json({ similarityArray }); + } +}; + +export const recommendUsers = async ( + req: RequestExtended, + res: Response, + next: NextFunction, +) => { + // modify during integration + const loggedInUserId = req.body.userId; + const user = await prisma.user.findUnique({ + where: { + id: loggedInUserId, + }, + include: { + tags: true, + }, + }); + const userTags = user?.tags; + + const allTags = await prisma.tag.findMany({}); + + if (userTags) { + const userTagsNormalized = normalizeTags(userTags, allTags); + + const allUsers = await prisma.user.findMany({ + include: { + tags: true, + }, + }); + + const userTagMap = new Map(); + + allUsers.forEach((user) => { + const eventTagsNormalized = normalizeTags(user.tags, allTags); + userTagMap.set(user.id, eventTagsNormalized); + }); + + const recommendationMap = new Map(); + + for (let [key, value] of userTagMap) { + let similarity = cosineSimilarity(userTagsNormalized, value); + recommendationMap.set(key, similarity); + } + + const similarityArray = Array.from(recommendationMap.entries()); + similarityArray.sort((a, b) => b[1] - a[1]); + + res.status(200).json({ similarityArray }); + } +}; diff --git a/backend/controllers/tag.controller.ts b/backend/controllers/tag.controller.ts new file mode 100644 index 00000000..45e90a72 --- /dev/null +++ b/backend/controllers/tag.controller.ts @@ -0,0 +1,190 @@ +import { RequestExtended } from "../middleware/verifyAuth"; +import { Response, NextFunction } from "express"; +import prisma from "../prisma/client"; + +export const readUserTags = async ( + req: RequestExtended, + res: Response, + next: NextFunction, +) => { + // modify during integration + const loggedInUserId = req.body.userId; + const user = await prisma.user.findUnique({ + where: { + id: loggedInUserId, + }, + include: { + tags: true, + }, + }); + + res.status(200).json({ tags: user?.tags }); +}; + +export const modifyUserTags = async ( + req: RequestExtended, + res: Response, + next: NextFunction, +) => { + // modify during integration + const loggedInUserId = req.body.userId; + const user = await prisma.user.findUnique({ + where: { + id: loggedInUserId, + }, + include: { + tags: true, + }, + }); + + console.log(user); + + for (const tag of req.body.tags) { + const foundTag = await prisma.tag.findUnique({ + where: { + name: tag, + }, + }); + + if (foundTag) { + await prisma.user.update({ + where: { + id: loggedInUserId, + }, + data: { + tags: { + connect: { + id: foundTag.id, + }, + }, + }, + }); + } + } + + res.status(200).json({ message: "Tags updated successfully" }); +}; + +export const readEventTags = async ( + req: RequestExtended, + res: Response, + next: NextFunction, +) => { + // modify during integration + const relevantEventId = req.body.eventId; + const event = await prisma.event.findUnique({ + where: { + id: relevantEventId, + }, + include: { + tags: true, + }, + }); + + res.status(200).json({ tags: event?.tags }); +}; + +export const modifyEventTags = async ( + req: RequestExtended, + res: Response, + next: NextFunction, +) => { + const relevantEventId = req.body.eventId; + const event = await prisma.event.findUnique({ + where: { + id: relevantEventId, + }, + include: { + tags: true, + }, + }); + + console.log(relevantEventId); + + for (const tag of req.body.tags) { + const foundTag = await prisma.tag.findUnique({ + where: { + name: tag, + }, + }); + + if (foundTag) { + await prisma.event.update({ + where: { + id: relevantEventId, + }, + data: { + tags: { + connect: { + id: foundTag.id, + }, + }, + }, + }); + } + } + + res.status(200).json({ message: "Tags updated successfully" }); +}; + +export const readPostTags = async ( + req: RequestExtended, + res: Response, + next: NextFunction, +) => { + // modify during integration + const relevantPostId = req.body.postId; + const post = await prisma.post.findUnique({ + where: { + id: relevantPostId, + }, + include: { + tags: true, + }, + }); + + res.status(200).json({ tags: post?.tags }); +}; + +export const modifyPostTags = async ( + req: RequestExtended, + res: Response, + next: NextFunction, +) => { + const relevantPostId = req.body.postId; + const post = await prisma.event.findUnique({ + where: { + id: relevantPostId, + }, + include: { + tags: true, + }, + }); + + console.log(relevantPostId); + + for (const tag of req.body.tags) { + const foundTag = await prisma.tag.findUnique({ + where: { + name: tag, + }, + }); + + if (foundTag) { + await prisma.user.update({ + where: { + id: relevantPostId, + }, + data: { + tags: { + connect: { + id: foundTag.id, + }, + }, + }, + }); + } + } + + res.status(200).json({ message: "Tags updated successfully" }); +}; diff --git a/backend/index.ts b/backend/index.ts index 09db2e51..1b82de2b 100644 --- a/backend/index.ts +++ b/backend/index.ts @@ -9,7 +9,9 @@ import event from "./routes/event.routes"; import institution from "./routes/institution.routes"; import user from "./routes/user.routes"; import org from "./routes/org.routes"; -import post from "./routes/post.routes"; +import recommendation from "./routes/rec.routes"; +import tag from "./routes/tag.routes"; +import UploadToS3, { upload } from "./utils/S3Uploader"; import { validateEnv } from "./utils/validateEnv"; const app = express(); @@ -52,13 +54,35 @@ app.use("/api/user", user); app.use("/api/institution", institution); app.use("/api/events", event); app.use("/api/orgs", org); -app.use("/api/post", post); +app.use("/api/tags", tag); +app.use("/api/recommend", recommendation); app.get("/Test", (req: Request, res: Response) => { console.log("The backend is hit"); res.json({ message: "Hello World!" }); }); +// Deprecated - Only for testing purposes +app.post( + "/api/upload", + upload.single("file"), + async (req: Request, res: Response) => { + if (!req.file) { + return res.status(400).send("No file uploaded."); + } + + try { + // Would need to generate a proper path here + const path = `new/path/${req.file.originalname}`; + await UploadToS3(req.file, path); + res.status(200).send("File uploaded successfully"); + } catch (error) { + console.error(error); + res.status(500).send("Error uploading the file"); + } + }, +); + // Global error handling middleware - Must be the last middleware app.use(errorHandler); @@ -80,11 +104,5 @@ if (process.env.ENV === "dev") { ); } -process.on("SIGINT", function () { - console.log("\nGracefully shutting down from SIGINT (Ctrl-C)"); - server.close(); - process.exit(0); -}); - export default app; export { server }; diff --git a/backend/prisma/data.ts b/backend/prisma/data.ts index f5b931db..07c0e4af 100644 --- a/backend/prisma/data.ts +++ b/backend/prisma/data.ts @@ -1,9 +1,10 @@ import { AppPermissionName, EventStatus, + Institution, + User, OrganizationStatus, ParticipationStatus, - User, UserOrgStatus, UserRole, UserType, @@ -77,8 +78,113 @@ export const ids = { 3: "f6700561-c5f5-11ee-83fd-6f8d6c450910", 4: "f6700562-c5f5-11ee-83fd-6f8d6c450910", }, + tagIds: { + 1: "1a574339-f3fc-49d5-8db0-564289f26c19", + 2: "1a574339-f3fc-49d5-8db0-564289f26c20", + 3: "1a574339-f3fc-49d5-8db0-564289f26c21", + 4: "1a574339-f3fc-49d5-8db0-564289f26c22", + 5: "1a574339-f3fc-49d5-8db0-564289f26c23", + 6: "1a574339-f3fc-49d5-8db0-564289f26c24", + 7: "1a574339-f3fc-49d5-8db0-564289f26c25", + 8: "1a574339-f3fc-49d5-8db0-564289f26c26", + 9: "1a574339-f3fc-49d5-8db0-564289f26c27", + 10: "1a574339-f3fc-49d5-8db0-564289f26c28", + 11: "1a574339-f3fc-49d5-8db0-564289f26c29", + 12: "1a574339-f3fc-49d5-8db0-564289f26c30", + 13: "1a574339-f3fc-49d5-8db0-564289f26c31", + 14: "1a574339-f3fc-49d5-8db0-564289f26c32", + 15: "1a574339-f3fc-49d5-8db0-564289f26c33", + 16: "1a574339-f3fc-49d5-8db0-564289f26c34", + 17: "1a574339-f3fc-49d5-8db0-564289f26c35", + 18: "1a574339-f3fc-49d5-8db0-564289f26c36", + 19: "1a574339-f3fc-49d5-8db0-564289f26c37", + 20: "1a574339-f3fc-49d5-8db0-564289f26c38", + }, }; +export const tags = [ + { + id: ids.tagIds[1], + name: "sports", + }, + { + id: ids.tagIds[2], + name: "fitness", + }, + { + id: ids.tagIds[3], + name: "culture", + }, + { + id: ids.tagIds[4], + name: "theatre", + }, + { + id: ids.tagIds[5], + name: "academic", + }, + { + id: ids.tagIds[6], + name: "music", + }, + { + id: ids.tagIds[7], + name: "community", + }, + { + id: ids.tagIds[8], + name: "tech", + }, + { + id: ids.tagIds[9], + name: "food", + }, + { + id: ids.tagIds[10], + name: "wellness", + }, + { + id: ids.tagIds[11], + name: "science", + }, + { + id: ids.tagIds[12], + name: "business", + }, + { + id: ids.tagIds[13], + name: "engineering", + }, + { + id: ids.tagIds[14], + name: "art", + }, + { + id: ids.tagIds[15], + name: "games", + }, + { + id: ids.tagIds[16], + name: "social", + }, + { + id: ids.tagIds[17], + name: "software", + }, + { + id: ids.tagIds[18], + name: "mechanical", + }, + { + id: ids.tagIds[19], + name: "electrical", + }, + { + id: ids.tagIds[20], + name: "outdoors", + }, +]; + export const locations = [ { placeId: "ChIJ1T-EnwNwcVMROrZStrE7bSY", diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 2fb1b00a..2af80dce 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -61,6 +61,52 @@ enum AppPermissionName { DELETE_ORGANIZATION } +// RECOMMENDATION MODELS +model Tag { + id String @id @default(uuid()) + name String @unique + + users User[] @relation("UserTags") + events Event[] @relation("EventTags") + posts Post[] @relation("PostTags") + UserTags UserTags[] + EventTags EventTags[] + PostTags PostTags[] + + @@map("tag") +} + +// Intermediate table for many-to-many relations +model UserTags { + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + userId String + tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade) + tagId String + + @@unique([userId, tagId]) + @@index([tagId]) +} + +model EventTags { + event Event @relation(fields: [eventId], references: [id], onDelete: Cascade) + eventId String + tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade) + tagId String + + @@unique([eventId, tagId]) + @@index([tagId]) +} + +model PostTags { + post Post @relation(fields: [postId], references: [id], onDelete: Cascade) + postId String + tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade) + tagId String + + @@unique([postId, tagId]) + @@index([tagId]) +} + // CORE MODELS model Institution { id String @id @default(uuid()) @@ -96,6 +142,9 @@ model User { subscriptions TopicSubscription[] UserOrganizationRole UserOrganizationRole[] + tags Tag[] @relation("UserTags") + UserTags UserTags[] + @@index([email]) @@index([institutionId]) @@map("user") @@ -143,6 +192,9 @@ model Event { eventResponses UserEventResponse[] locationPlaceId String + tags Tag[] @relation("EventTags") + EventTags EventTags[] + @@index([userId]) @@index([organizationId]) @@index([locationPlaceId]) @@ -175,7 +227,10 @@ model Post { organization Organization? @relation(fields: [organizationId], references: [id], onDelete: Cascade) comments Comment[] + + tags Tag[] @relation("PostTags") postTags PostTag[] + PostTags PostTags[] @@index([userId]) @@index([organizationId]) diff --git a/backend/prisma/seed.ts b/backend/prisma/seed.ts index a7b095e5..01b93e91 100644 --- a/backend/prisma/seed.ts +++ b/backend/prisma/seed.ts @@ -13,6 +13,7 @@ import { postTags, programs, roles, + tags, topics, topicSubscriptions, userEventResponses, @@ -31,6 +32,9 @@ const load = async () => { await prisma.user.deleteMany(); console.log("Deleted records in the User table"); + await prisma.tag.deleteMany(); + console.log("Delete records in the Tag table"); + await prisma.event.deleteMany(); console.log("Deleted records in the Event table"); @@ -96,6 +100,11 @@ const load = async () => { }); console.log("Added User data"); + await prisma.tag.createMany({ + data: tags, + }); + console.log("Added Tag data"); + await prisma.event.createMany({ data: events, }); diff --git a/backend/routes/rec.routes.ts b/backend/routes/rec.routes.ts new file mode 100644 index 00000000..7a98ea0e --- /dev/null +++ b/backend/routes/rec.routes.ts @@ -0,0 +1,14 @@ +import express from "express"; +import { + recommendEvents, + recommendPosts, + recommendUsers, +} from "../controllers/rec.controller"; + +const router = express.Router(); + +router.get("/recommendEvents", recommendEvents); +router.get("/recommendPosts", recommendPosts); +router.get("/recommendUsers", recommendUsers); + +export default router; diff --git a/backend/routes/tag.routes.ts b/backend/routes/tag.routes.ts new file mode 100644 index 00000000..2e8821ee --- /dev/null +++ b/backend/routes/tag.routes.ts @@ -0,0 +1,20 @@ +import express from "express"; +import { + readUserTags, + modifyUserTags, + readEventTags, + modifyEventTags, + readPostTags, + modifyPostTags, +} from "../controllers/tag.controller"; + +const router = express.Router(); + +router.get("/readUserTags", readUserTags); +router.post("/modifyUserTags", modifyUserTags); +router.get("/readEventTags", readEventTags); +router.post("/modifyEventTags", modifyEventTags); +router.get("/readPostTags", readPostTags); +router.post("/modifyPostTags", modifyPostTags); + +export default router; diff --git a/backend/utils/cosineSimilarity.ts b/backend/utils/cosineSimilarity.ts new file mode 100644 index 00000000..b381e3cf --- /dev/null +++ b/backend/utils/cosineSimilarity.ts @@ -0,0 +1,26 @@ +const dotProduct = (vectorA: number[], vectorB: number[]) => { + return vectorA.reduce( + (acc: number, value: number, index: number) => acc + value * vectorB[index], + 0, + ); +}; + +const magnitude = (vector: number[]) => { + return Math.sqrt( + vector.reduce((acc: number, value: number) => acc + value ** 2, 0), + ); +}; + +const cosineSimilarity = (vectorA: number[], vectorB: number[]) => { + const dotProd = dotProduct(vectorA, vectorB); + const magA = magnitude(vectorA); + const magB = magnitude(vectorB); + + if (magA === 0 || magB === 0) { + return 0; + } + + return dotProd / (magA * magB); +}; + +export default cosineSimilarity; diff --git a/backend/utils/normalizeTags.ts b/backend/utils/normalizeTags.ts new file mode 100644 index 00000000..9931627b --- /dev/null +++ b/backend/utils/normalizeTags.ts @@ -0,0 +1,25 @@ +type tag = + | { + id: number; + name: string; + }[] + | undefined; + +const normalizeTags = ( + tagsDenormalized: tag[] | any[], + tagsReference: tag[] | any[], +) => { + const normalizedTags: number[] = new Array(tagsReference.length).fill(2); + + tagsDenormalized.forEach((tagDenormalized) => { + const tagIndex = tagsReference.findIndex( + (tagsReference) => tagsReference.id === tagDenormalized.id, + ); + if (tagIndex !== -1) { + normalizedTags[tagIndex] = 2; + } + }); + return normalizedTags; +}; + +export default normalizeTags; diff --git a/backend/utils/tagMap.ts b/backend/utils/tagMap.ts new file mode 100644 index 00000000..280fcfff --- /dev/null +++ b/backend/utils/tagMap.ts @@ -0,0 +1,24 @@ +const tagMapping: Record = { + Sports: 0, + Fitness: 1, + Culture: 2, + Theatre: 3, + Academic: 4, + Music: 5, + Community: 6, + Tech: 7, + Food: 8, + Wellness: 9, + Science: 10, + Business: 11, + Engineering: 12, + Art: 13, + Games: 14, + Social: 15, + Software: 16, + Mechanical: 17, + Electrical: 18, + Outdoors: 19, +}; + +export default tagMapping;