From f7c8fbe48a7ecca2183cf2b502b0122cf1f5e8e1 Mon Sep 17 00:00:00 2001 From: Fatih20 <13521060@std.stei.itb.ac.id> Date: Tue, 2 Jun 2026 12:59:52 +0700 Subject: [PATCH 1/2] feat: initial work on speed dating --- README.matchmaking-client.md | 299 ++++++++++ README.md | 13 + firestore.indexes.json | 17 +- functions/package.json | 3 +- functions/src/controllers/match_controller.ts | 550 ++++++++++++++++++ functions/src/models/match.ts | 34 ++ functions/src/routes/index.ts | 2 + functions/src/routes/match.ts | 42 ++ functions/src/types/config.ts | 14 +- functions/src/utils/fake_data_populator.ts | 21 + package-lock.json | 6 + 11 files changed, 995 insertions(+), 6 deletions(-) create mode 100644 README.matchmaking-client.md create mode 100644 functions/src/controllers/match_controller.ts create mode 100644 functions/src/models/match.ts create mode 100644 functions/src/routes/match.ts create mode 100644 package-lock.json diff --git a/README.matchmaking-client.md b/README.matchmaking-client.md new file mode 100644 index 0000000..a120bfb --- /dev/null +++ b/README.matchmaking-client.md @@ -0,0 +1,299 @@ +# Matchmaking Client Integration Guide + +This document is for frontend/client implementors integrating the matchmaking feature. + +## Feature summary + +- Participants opt in once. +- Opted-in participants can fetch a random swipe deck. +- A swipe can be `left` or `right`. +- A match is created only when both users swipe `right`. +- Users can list their matches and view details of a specific match. + +## Non-negotiable product behavior + +- Only confirmed RSVP users can opt in. +- Mentors and admins are excluded from matchmaking. +- Opt-in is irreversible in this version. +- No undo, unmatch, or re-swipe. +- No notification system yet. +- No in-app chat. Contact happens outside the app. + +## Auth and request requirements + +Matchmaking endpoints rely on the existing session-cookie auth + CSRF middleware. + +Client requirements: + +1. Send cookies with requests (for `__session`). +2. Send `x-xsrf-token` header for write requests (`POST`) using the token value from the `XSRF-TOKEN` cookie. +3. Use `Content-Type: application/json` for JSON bodies. + +If these are missing, the backend may return `401` or `403`. + +## Base route + +All routes are under: + +- `/match` + +If your app prefixes API routes (for example `/api`), apply the same prefix as other existing endpoints in your app. + +## Endpoint contracts + +## 1) Get matchmaking config + +- `GET /match/config` + +Success: + +```json +{ + "data": { + "isMatchOpen": true, + "startDate": "...", + "endDate": "..." + } +} +``` + +Notes: + +- If config document does not exist, backend returns: + +```json +{ + "status": 400, + "error": "Config not found" +} +``` + +## 2) Get my matchmaking status + +- `GET /match/status` + +Success: + +```json +{ + "data": { + "optedIn": true, + "eligible": true + } +} +``` + +Use this to decide whether to show: + +- Ineligible state +- Opt-in CTA +- Swipe experience + +## 3) Opt in + +- `POST /match/opt-in` +- Body: none + +Success: + +```json +{ + "message": "Opt-in successful" +} +``` + +Already opted in: + +```json +{ + "message": "You are already opted in" +} +``` + +Ineligible: + +```json +{ + "error": "You are not eligible to opt in" +} +``` + +Status code: `403`. + +## 4) Get swipe deck + +- `GET /match/deck?limit=10` +- `limit` is optional. Default = `10`. Backend caps high values. + +Success: + +```json +{ + "data": [ + { + "id": "uid_123", + "firstName": "Jane", + "lastName": "Doe", + "school": "Example High School" + } + ] +} +``` + +Important behaviors: + +- Returns empty array when exhausted: + +```json +{ + "data": [] +} +``` + +- Requires opted in (`403` otherwise). +- Requires matchmaking open (`403` otherwise). + +## 5) Swipe on a user + +- `POST /match/swipe` +- Body: + +```json +{ + "targetId": "uid_target", + "direction": "left" +} +``` + +or + +```json +{ + "targetId": "uid_target", + "direction": "right" +} +``` + +Success (no match): + +```json +{ + "matched": false, + "match": null +} +``` + +Success (new match): + +```json +{ + "matched": true, + "match": { + "id": "uidA_uidB" + } +} +``` + +Validation and errors: + +- `400` missing fields or invalid direction +- `400` self-swipe not allowed +- `400` already swiped that target +- `400` target unavailable +- `403` not opted in / matchmaking closed +- `429` rate-limited (more than 15 swipes in 60 seconds) + +## 6) Get my matches + +- `GET /match/matches` + +Success: + +```json +{ + "data": [ + { + "id": "uidA_uidB", + "createdAt": 1717000000, + "user": { + "id": "uid_other", + "firstName": "Alex", + "lastName": "Smith", + "school": "Example University" + } + } + ] +} +``` + +Notes: + +- This is where the first swiper eventually discovers matches (no push notification yet). + +## 7) Get one match detail + +- `GET /match/matches/:id` + +Success: + +```json +{ + "data": { + "id": "uidA_uidB", + "createdAt": 1717000000, + "user": { + "id": "uid_other", + "firstName": "Alex", + "lastName": "Smith", + "school": "Example University" + } + } +} +``` + +Errors: + +- `404` if match not found +- `403` if current user is not part of that match + +## Recommended client state machine + +At page load: + +1. Call `GET /match/status`. +2. If `eligible === false`, show ineligible view. +3. If `eligible === true` and `optedIn === false`, show opt-in CTA. +4. After opt-in success, call `GET /match/config` then `GET /match/deck`. +5. If config says closed, show closed state. + +During swipe loop: + +1. Render current card. +2. On swipe submit `POST /match/swipe`. +3. Optimistically remove card from local deck. +4. If response has `matched: true`, show matched modal/toast. +5. When deck has low remaining cards, prefetch next `GET /match/deck`. +6. If deck becomes empty, show exhausted state. + +Matches screen: + +1. Call `GET /match/matches`. +2. Render list sorted client-side if desired. +3. Optional detail screen: call `GET /match/matches/:id`. + +## UX and error handling guidance + +- Handle `429` with a user-friendly cooldown message. +- Handle `403` closed status with a dedicated message, not a generic error. +- Handle `400` already-swiped silently if it occurs during retries. +- Keep copy explicit that opt-in is permanent in this release. + +## Deferred items (do not block implementation) + +- Discord handle for hackers in match payload +- Incoming likes view +- Notification delivery when matched +- Block/report backend flows +- In-app chat + +Build UI with extension points for these additions, but do not wait for them. diff --git a/README.md b/README.md index ed3ec38..d37b7c1 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,19 @@ Required environment variables: - `FIREBASE_CLIENT_EMAIL` - `NODE_ENV` +## Matchmaking Caveats and Development Notes + +1. Match cards are intentionally minimal (`firstName`, `lastName`, `school`) because the current user profile does not include richer dating fields or photo support. +2. Discord handle exposure for matched hackers is deferred; hacker documents do not currently store `discordUsername`. +3. Only the second swiper sees an immediate match response. The first swiper sees new matches on the next `GET /match/matches` fetch because notifications are deferred. +4. Eligibility is snapshotted by opt-in (`matchEnabled`). If a user's RSVP status changes later, they remain in the matchmaking pool by design. +5. Opt-in is irreversible for this MVP. There is no opt-out, undo swipe, or unmatch flow. +6. Deck generation reads all opted-in users plus the caller's prior swipes. This is acceptable for the expected ~500 participant scale. +7. Reporting is frontend-only for now (mailto to organizers). There is no backend reports collection in this phase. +8. Incoming likes are intentionally deferred, but the schema is designed to support it later via `swipes` queries on `targetId` and `direction`. +9. There is no automated test suite in this repository. Validate behavior with Firebase emulators (`cd functions && npm run serve`). +10. `config/matchConfig` is a required runtime document. In local development it can be seeded by `FakeDataPopulator`; production/staging must create and maintain it manually. + ## 🤝 Contributing 1. Fork the repository diff --git a/firestore.indexes.json b/firestore.indexes.json index 415027e..04894a9 100644 --- a/firestore.indexes.json +++ b/firestore.indexes.json @@ -1,4 +1,19 @@ { - "indexes": [], + "indexes": [ + { + "collectionGroup": "swipes", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "swiperId", + "order": "ASCENDING" + }, + { + "fieldPath": "createdAt", + "order": "DESCENDING" + } + ] + } + ], "fieldOverrides": [] } diff --git a/functions/package.json b/functions/package.json index 92fad9f..87375a2 100644 --- a/functions/package.json +++ b/functions/package.json @@ -52,5 +52,6 @@ "firebase-functions-test": "^3.1.0", "typescript": "^5.8.2" }, - "private": true + "private": true, + "packageManager": "npm@11.12.1+sha512.cdca14b85d647b3192028d02aadbe82d75f79a446aceea9874be98e6d768f20ebd3555770a48d0e9906106007877bbc690f715e9372f2e2dc644a3c3157fb14c" } diff --git a/functions/src/controllers/match_controller.ts b/functions/src/controllers/match_controller.ts new file mode 100644 index 0000000..e77a713 --- /dev/null +++ b/functions/src/controllers/match_controller.ts @@ -0,0 +1,550 @@ +import { Request, Response } from "express"; +import * as functions from "firebase-functions"; +import { db } from "../config/firebase"; +import { APPLICATION_STATUS } from "../types/application_types"; +import { Match, MatchCardDTO, Swipe, SwipeDirection, formatMatchCard } from "../models/match"; +import { MatchConfig } from "../types/config"; + +const CONFIG = "config"; +const MATCH_CONFIG_DOC = "matchConfig"; +const USERS = "users"; +const SWIPES = "swipes"; +const MATCHES = "matches"; +const RATE_LIMIT_PER_MINUTE = 15; +const DEFAULT_DECK_SIZE = 10; +const MAX_DECK_SIZE = 50; + +type MatchUserDoc = { + id?: string; + firstName?: string; + lastName?: string; + name?: string; + school?: string; + status?: string; + mentor?: boolean; + admin?: boolean; + matchEnabled?: boolean; + matchEnabledAt?: number; +}; + +const nowUnixSeconds = (): number => Math.floor(Date.now() / 1000); + +const getUidFromRequest = (req: Request): string | null => { + if (!req.user?.uid) { + return null; + } + return req.user.uid; +}; + +const parseDeckLimit = (rawLimit: unknown): number => { + if (!rawLimit) { + return DEFAULT_DECK_SIZE; + } + + const numericLimit = parseInt(rawLimit.toString(), 10); + if (isNaN(numericLimit) || numericLimit <= 0) { + return DEFAULT_DECK_SIZE; + } + + return Math.min(numericLimit, MAX_DECK_SIZE); +}; + +const isUserEligibleForOptIn = (userData: MatchUserDoc): boolean => { + return ( + userData.status === APPLICATION_STATUS.CONFIRMED_RSVP && + userData.mentor !== true && + userData.admin !== true + ); +}; + +const isUserOptedIn = (userData: MatchUserDoc): boolean => { + return userData.matchEnabled === true; +}; + +const isUserMatchCandidate = (userData: MatchUserDoc): boolean => { + return isUserOptedIn(userData) && userData.mentor !== true && userData.admin !== true; +}; + +const buildMatchCardFromUser = ( + userId: string, + userData: MatchUserDoc +): MatchCardDTO => { + const fallbackName = userData.name || ""; + const fallbackParts = fallbackName.trim().split(/\s+/); + const fallbackFirst = fallbackParts[0] || ""; + const fallbackLast = + fallbackParts.length > 1 ? fallbackParts.slice(1).join(" ") : ""; + + return formatMatchCard({ + id: userId, + firstName: userData.firstName || fallbackFirst, + lastName: userData.lastName || fallbackLast, + school: userData.school || "", + }); +}; + +const getMatchConfig = async (): Promise => { + const snapshot = await db.collection(CONFIG).doc(MATCH_CONFIG_DOC).get(); + if (!snapshot.exists) { + return null; + } + + const data = snapshot.data() as MatchConfig | undefined; + if (!data) { + return null; + } + + return data; +}; + +const isMatchOpen = async (): Promise => { + const config = await getMatchConfig(); + if (!config) { + return false; + } + return config.isMatchOpen === true; +}; + +const getCurrentUserDoc = async ( + uid: string +): Promise<{ userData: MatchUserDoc | null; exists: boolean }> => { + const snapshot = await db.collection(USERS).doc(uid).get(); + if (!snapshot.exists) { + return { userData: null, exists: false }; + } + + return { + userData: snapshot.data() as MatchUserDoc, + exists: true, + }; +}; + +const getSwipedTargetIds = async (uid: string): Promise> => { + const snapshot = await db.collection(SWIPES).where("swiperId", "==", uid).get(); + const swipedIds = new Set(); + snapshot.docs.forEach((doc) => { + const data = doc.data() as Swipe; + if (data.targetId) { + swipedIds.add(data.targetId); + } + }); + return swipedIds; +}; + +const shuffle = (items: T[]): T[] => { + const copy = [...items]; + for (let i = copy.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [copy[i], copy[j]] = [copy[j], copy[i]]; + } + return copy; +}; + +export const getMatchConfigHandler = async ( + _req: Request, + res: Response +): Promise => { + try { + const matchConfig = await getMatchConfig(); + if (!matchConfig) { + return res.status(400).json({ + status: 400, + error: "Config not found", + }); + } + + return res.status(200).json({ + data: matchConfig, + }); + } catch (error) { + functions.logger.error( + `Error when trying getMatchConfigHandler: ${(error as Error).message}` + ); + return res.status(500).json({ error: (error as Error).message }); + } +}; + +export const getMatchStatus = async ( + req: Request, + res: Response +): Promise => { + try { + const uid = getUidFromRequest(req); + if (!uid) { + return res.status(401).json({ error: "Unauthorized" }); + } + + const { userData, exists } = await getCurrentUserDoc(uid); + if (!exists || !userData) { + return res.status(404).json({ error: "User not found" }); + } + + return res.status(200).json({ + data: { + optedIn: isUserOptedIn(userData), + eligible: isUserEligibleForOptIn(userData), + }, + }); + } catch (error) { + functions.logger.error( + `Error when trying getMatchStatus: ${(error as Error).message}` + ); + return res.status(500).json({ error: (error as Error).message }); + } +}; + +export const optInToMatch = async ( + req: Request, + res: Response +): Promise => { + try { + const uid = getUidFromRequest(req); + if (!uid) { + return res.status(401).json({ error: "Unauthorized" }); + } + + const { userData, exists } = await getCurrentUserDoc(uid); + if (!exists || !userData) { + return res.status(404).json({ error: "User not found" }); + } + + if (isUserOptedIn(userData)) { + return res.status(200).json({ + message: "You are already opted in", + }); + } + + if (!isUserEligibleForOptIn(userData)) { + return res.status(403).json({ + error: "You are not eligible to opt in", + }); + } + + await db.collection(USERS).doc(uid).set( + { + matchEnabled: true, + matchEnabledAt: nowUnixSeconds(), + }, + { merge: true } + ); + + return res.status(200).json({ + message: "Opt-in successful", + }); + } catch (error) { + functions.logger.error( + `Error when trying optInToMatch: ${(error as Error).message}` + ); + return res.status(500).json({ error: (error as Error).message }); + } +}; + +export const getDeck = async ( + req: Request, + res: Response +): Promise => { + try { + const uid = getUidFromRequest(req); + if (!uid) { + return res.status(401).json({ error: "Unauthorized" }); + } + + const { userData, exists } = await getCurrentUserDoc(uid); + if (!exists || !userData) { + return res.status(404).json({ error: "User not found" }); + } + + if (!isUserOptedIn(userData)) { + return res.status(403).json({ error: "You must opt in first" }); + } + + const matchOpen = await isMatchOpen(); + if (!matchOpen) { + return res.status(403).json({ error: "Matchmaking is currently closed" }); + } + + const limit = parseDeckLimit(req.query.limit); + const [swipedTargetIds, optedInUsersSnapshot] = await Promise.all([ + getSwipedTargetIds(uid), + db.collection(USERS).where("matchEnabled", "==", true).get(), + ]); + + const availableCards: MatchCardDTO[] = []; + optedInUsersSnapshot.docs.forEach((doc) => { + const candidateId = doc.id; + const candidateData = doc.data() as MatchUserDoc; + + if (candidateId === uid) { + return; + } + + if (swipedTargetIds.has(candidateId)) { + return; + } + + if (!isUserMatchCandidate(candidateData)) { + return; + } + + availableCards.push(buildMatchCardFromUser(candidateId, candidateData)); + }); + + const shuffledCards = shuffle(availableCards).slice(0, limit); + + return res.status(200).json({ + data: shuffledCards, + }); + } catch (error) { + functions.logger.error(`Error when trying getDeck: ${(error as Error).message}`); + return res.status(500).json({ error: (error as Error).message }); + } +}; + +const isValidSwipeDirection = (direction: string): direction is SwipeDirection => { + return ( + direction === SwipeDirection.LEFT || direction === SwipeDirection.RIGHT + ); +}; + +const getSortedMatchId = (uidA: string, uidB: string): string => { + return [uidA, uidB].sort().join("_"); +}; + +export const swipe = async (req: Request, res: Response): Promise => { + try { + const uid = getUidFromRequest(req); + if (!uid) { + return res.status(401).json({ error: "Unauthorized" }); + } + + const { targetId, direction } = req.body as { + targetId?: string; + direction?: string; + }; + + if (!targetId || !direction) { + return res.status(400).json({ + error: "targetId and direction are required", + }); + } + + if (!isValidSwipeDirection(direction)) { + return res.status(400).json({ + error: "direction must be either 'left' or 'right'", + }); + } + + if (targetId === uid) { + return res.status(400).json({ error: "You cannot swipe yourself" }); + } + + const { userData: currentUserData, exists: currentUserExists } = + await getCurrentUserDoc(uid); + if (!currentUserExists || !currentUserData) { + return res.status(404).json({ error: "User not found" }); + } + + if (!isUserOptedIn(currentUserData)) { + return res.status(403).json({ error: "You must opt in first" }); + } + + const matchOpen = await isMatchOpen(); + if (!matchOpen) { + return res.status(403).json({ error: "Matchmaking is currently closed" }); + } + + const targetSnapshot = await db.collection(USERS).doc(targetId).get(); + if (!targetSnapshot.exists) { + return res.status(400).json({ error: "Invalid target user" }); + } + + const targetData = targetSnapshot.data() as MatchUserDoc; + if (!isUserMatchCandidate(targetData)) { + return res.status(400).json({ error: "Target user is not available" }); + } + + const currentTime = nowUnixSeconds(); + const rateLimitCutoff = currentTime - 60; + const recentSwipeSnapshot = await db + .collection(SWIPES) + .where("swiperId", "==", uid) + .where("createdAt", ">", rateLimitCutoff) + .orderBy("createdAt", "desc") + .limit(RATE_LIMIT_PER_MINUTE) + .get(); + + if (recentSwipeSnapshot.size >= RATE_LIMIT_PER_MINUTE) { + return res.status(429).json({ + error: "Too many swipes. Please try again shortly.", + }); + } + + const ownSwipeDocId = `${uid}_${targetId}`; + const reciprocalSwipeDocId = `${targetId}_${uid}`; + const ownSwipeRef = db.collection(SWIPES).doc(ownSwipeDocId); + const reciprocalSwipeRef = db.collection(SWIPES).doc(reciprocalSwipeDocId); + + let matched = false; + let matchId: string | null = null; + + await db.runTransaction(async (transaction) => { + const ownSwipeSnapshot = await transaction.get(ownSwipeRef); + if (ownSwipeSnapshot.exists) { + throw new Error("ALREADY_SWIPED"); + } + + let reciprocalSwipeData: Swipe | null = null; + if (direction === SwipeDirection.RIGHT) { + const reciprocalSwipeSnapshot = await transaction.get(reciprocalSwipeRef); + if (reciprocalSwipeSnapshot.exists) { + reciprocalSwipeData = reciprocalSwipeSnapshot.data() as Swipe; + } + } + + transaction.set(ownSwipeRef, { + swiperId: uid, + targetId, + direction, + createdAt: currentTime, + } as Swipe); + + if ( + direction === SwipeDirection.RIGHT && + reciprocalSwipeData && + reciprocalSwipeData.direction === SwipeDirection.RIGHT + ) { + matchId = getSortedMatchId(uid, targetId); + const matchRef = db.collection(MATCHES).doc(matchId); + const sortedUsers = [uid, targetId].sort() as [string, string]; + + transaction.set(matchRef, { + users: sortedUsers, + createdAt: currentTime, + } as Match); + matched = true; + } + }); + + return res.status(200).json({ + matched, + match: matched ? { id: matchId } : null, + }); + } catch (error) { + if ((error as Error).message === "ALREADY_SWIPED") { + return res.status(400).json({ + error: "You have already swiped this user", + }); + } + functions.logger.error(`Error when trying swipe: ${(error as Error).message}`); + return res.status(500).json({ error: (error as Error).message }); + } +}; + +export const getMatches = async ( + req: Request, + res: Response +): Promise => { + try { + const uid = getUidFromRequest(req); + if (!uid) { + return res.status(401).json({ error: "Unauthorized" }); + } + + const { userData, exists } = await getCurrentUserDoc(uid); + if (!exists || !userData) { + return res.status(404).json({ error: "User not found" }); + } + + if (!isUserOptedIn(userData)) { + return res.status(403).json({ error: "You must opt in first" }); + } + + const matchesSnapshot = await db + .collection(MATCHES) + .where("users", "array-contains", uid) + .get(); + + const hydratedMatches = await Promise.all( + matchesSnapshot.docs.map(async (doc) => { + const matchData = doc.data() as Match; + const otherUserId = (matchData.users || []).find((id) => id !== uid); + if (!otherUserId) { + return null; + } + + const otherUserSnapshot = await db.collection(USERS).doc(otherUserId).get(); + if (!otherUserSnapshot.exists) { + return null; + } + + const otherUserData = otherUserSnapshot.data() as MatchUserDoc; + + return { + id: doc.id, + createdAt: matchData.createdAt, + user: buildMatchCardFromUser(otherUserId, otherUserData), + }; + }) + ); + + return res.status(200).json({ + data: hydratedMatches.filter((item) => item !== null), + }); + } catch (error) { + functions.logger.error( + `Error when trying getMatches: ${(error as Error).message}` + ); + return res.status(500).json({ error: (error as Error).message }); + } +}; + +export const getMatchById = async ( + req: Request, + res: Response +): Promise => { + try { + const uid = getUidFromRequest(req); + if (!uid) { + return res.status(401).json({ error: "Unauthorized" }); + } + + const { id } = req.params; + if (!id) { + return res.status(400).json({ error: "id is required" }); + } + + const matchSnapshot = await db.collection(MATCHES).doc(id).get(); + if (!matchSnapshot.exists) { + return res.status(404).json({ error: "Match not found" }); + } + + const matchData = matchSnapshot.data() as Match; + if (!matchData.users || !matchData.users.includes(uid)) { + return res.status(403).json({ error: "Forbidden" }); + } + + const otherUserId = matchData.users.find((userId) => userId !== uid); + if (!otherUserId) { + return res.status(404).json({ error: "Matched user not found" }); + } + + const otherUserSnapshot = await db.collection(USERS).doc(otherUserId).get(); + if (!otherUserSnapshot.exists) { + return res.status(404).json({ error: "Matched user not found" }); + } + + const otherUserData = otherUserSnapshot.data() as MatchUserDoc; + return res.status(200).json({ + data: { + id: matchSnapshot.id, + createdAt: matchData.createdAt, + user: buildMatchCardFromUser(otherUserId, otherUserData), + }, + }); + } catch (error) { + functions.logger.error( + `Error when trying getMatchById: ${(error as Error).message}` + ); + return res.status(500).json({ error: (error as Error).message }); + } +}; diff --git a/functions/src/models/match.ts b/functions/src/models/match.ts new file mode 100644 index 0000000..7e1b96d --- /dev/null +++ b/functions/src/models/match.ts @@ -0,0 +1,34 @@ +export enum SwipeDirection { + LEFT = "left", + RIGHT = "right", +} + +export interface Swipe { + id?: string; // `${swiperId}_${targetId}` + swiperId: string; + targetId: string; + direction: SwipeDirection; + createdAt: number; // unix timestamp in seconds +} + +export interface Match { + id?: string; // `${uidLow}_${uidHigh}` + users: [string, string]; + createdAt: number; // unix timestamp in seconds +} + +export interface MatchCardDTO { + id: string; + firstName: string; + lastName: string; + school: string; +} + +export const formatMatchCard = ( + data: Partial & { id?: string } +): MatchCardDTO => ({ + id: data.id || "", + firstName: data.firstName || "", + lastName: data.lastName || "", + school: data.school || "", +}); diff --git a/functions/src/routes/index.ts b/functions/src/routes/index.ts index 3f5ee4a..1bd2967 100644 --- a/functions/src/routes/index.ts +++ b/functions/src/routes/index.ts @@ -4,6 +4,7 @@ import applicationRoutes from "./application"; import userRoutes from "./user"; import ticketRoutes from "./ticket"; import mentorshipRoutes from "./mentorship"; +import matchRoutes from "./match"; const router: Router = express.Router(); @@ -12,5 +13,6 @@ router.use("/users", userRoutes); router.use("/application", applicationRoutes) router.use("/tickets", ticketRoutes); router.use("/mentorship", mentorshipRoutes) +router.use("/match", matchRoutes); export default router; diff --git a/functions/src/routes/match.ts b/functions/src/routes/match.ts new file mode 100644 index 0000000..cb7712a --- /dev/null +++ b/functions/src/routes/match.ts @@ -0,0 +1,42 @@ +import express, { Request, Response } from "express"; +import { + getDeck, + getMatchById, + getMatchConfigHandler, + getMatches, + getMatchStatus, + optInToMatch, + swipe, +} from "../controllers/match_controller"; + +const router = express.Router(); + +router.get("/config", async (req: Request, res: Response) => { + await getMatchConfigHandler(req, res); +}); + +router.get("/status", async (req: Request, res: Response) => { + await getMatchStatus(req, res); +}); + +router.post("/opt-in", async (req: Request, res: Response) => { + await optInToMatch(req, res); +}); + +router.get("/deck", async (req: Request, res: Response) => { + await getDeck(req, res); +}); + +router.post("/swipe", async (req: Request, res: Response) => { + await swipe(req, res); +}); + +router.get("/matches", async (req: Request, res: Response) => { + await getMatches(req, res); +}); + +router.get("/matches/:id", async (req: Request, res: Response) => { + await getMatchById(req, res); +}); + +export default router; diff --git a/functions/src/types/config.ts b/functions/src/types/config.ts index 39614ff..ee39487 100644 --- a/functions/src/types/config.ts +++ b/functions/src/types/config.ts @@ -1,7 +1,13 @@ import { Timestamp } from "firebase-admin/firestore"; export interface MentorshipConfig { - isMentorshipOpen: boolean; // whether or not participant can start to book mentorship slots - mentorshipStartDate: Timestamp; - mentorshipEndDate: Timestamp; -} \ No newline at end of file + isMentorshipOpen: boolean; // whether or not participant can start to book mentorship slots + mentorshipStartDate: Timestamp; + mentorshipEndDate: Timestamp; +} + +export interface MatchConfig { + isMatchOpen: boolean; + startDate: Timestamp; + endDate: Timestamp; +} diff --git a/functions/src/utils/fake_data_populator.ts b/functions/src/utils/fake_data_populator.ts index d0db596..6ed1c9c 100644 --- a/functions/src/utils/fake_data_populator.ts +++ b/functions/src/utils/fake_data_populator.ts @@ -41,6 +41,8 @@ export class FakeDataPopulator { async generateFakeData() { log("generateFakeData"); + await this.ensureMatchConfigDocument(); + const generateDocument = await this.getGenerateDocument().get(); if (!generateDocument.exists) { @@ -53,6 +55,25 @@ export class FakeDataPopulator { } } + /** + * Ensures matchmaking config exists for local development. + */ + private async ensureMatchConfigDocument(): Promise { + const matchConfigRef = this.firestoreDatabase + .collection("config") + .doc("matchConfig"); + const matchConfigDoc = await matchConfigRef.get(); + if (matchConfigDoc.exists) { + return; + } + + await matchConfigRef.set({ + isMatchOpen: false, + startDate: firestore.Timestamp.now(), + endDate: firestore.Timestamp.now(), + }); + } + /** * Generates fake user data and populates the Firestore database. * @private diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..cb8a8ee --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "web-be", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} From eb99042b687ce47135560c460e8b60979f94738b Mon Sep 17 00:00:00 2001 From: Fatih20 <13521060@std.stei.itb.ac.id> Date: Sun, 7 Jun 2026 21:34:41 +0700 Subject: [PATCH 2/2] feat: improve matches --- firestore.indexes.json | 32 ++++ firestore.rules | 10 ++ functions/src/controllers/match_controller.ts | 169 +++++++++++++++++- functions/src/models/match.ts | 43 +++++ functions/src/models/notification.ts | 45 +++++ 5 files changed, 292 insertions(+), 7 deletions(-) create mode 100644 functions/src/models/notification.ts diff --git a/firestore.indexes.json b/firestore.indexes.json index 04894a9..35725d7 100644 --- a/firestore.indexes.json +++ b/firestore.indexes.json @@ -13,6 +13,38 @@ "order": "DESCENDING" } ] + }, + { + "collectionGroup": "notifications", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "userId", + "order": "ASCENDING" + }, + { + "fieldPath": "createdAt", + "order": "DESCENDING" + } + ] + }, + { + "collectionGroup": "notifications", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "userId", + "order": "ASCENDING" + }, + { + "fieldPath": "type", + "order": "ASCENDING" + }, + { + "fieldPath": "createdAt", + "order": "DESCENDING" + } + ] } ], "fieldOverrides": [] diff --git a/firestore.rules b/firestore.rules index 3ae7c62..a45c395 100644 --- a/firestore.rules +++ b/firestore.rules @@ -2,6 +2,16 @@ rules_version = '2'; service cloud.firestore { match /databases/{database}/documents { + match /notifications/{notificationId} { + allow read: if request.auth != null && + request.auth.uid == resource.data.userId; + allow update: if request.auth != null && + request.auth.uid == resource.data.userId && + request.resource.data.diff(resource.data).affectedKeys().hasOnly(["seen"]) && + request.resource.data.seen is bool; + allow create, delete: if false; + } + match /users/{userId} { allow read, write: if request.auth != null && request.auth.uid == userId; } diff --git a/functions/src/controllers/match_controller.ts b/functions/src/controllers/match_controller.ts index e77a713..b090873 100644 --- a/functions/src/controllers/match_controller.ts +++ b/functions/src/controllers/match_controller.ts @@ -1,8 +1,21 @@ import { Request, Response } from "express"; import * as functions from "firebase-functions"; +import { Timestamp } from "firebase-admin/firestore"; import { db } from "../config/firebase"; import { APPLICATION_STATUS } from "../types/application_types"; -import { Match, MatchCardDTO, Swipe, SwipeDirection, formatMatchCard } from "../models/match"; +import { + HackCardProfile, + Match, + MatchCardDTO, + MatchDeckCardDTO, + MatchDetailDTO, + Swipe, + SwipeDirection, + formatMatchCard, + formatMatchDeckCard, + formatMatchDetail, +} from "../models/match"; +import { Notification, buildMatchNotification } from "../models/notification"; import { MatchConfig } from "../types/config"; const CONFIG = "config"; @@ -10,6 +23,8 @@ const MATCH_CONFIG_DOC = "matchConfig"; const USERS = "users"; const SWIPES = "swipes"; const MATCHES = "matches"; +const NOTIFICATIONS = "notifications"; +const HACK_CARDS = "hack_cards"; const RATE_LIMIT_PER_MINUTE = 15; const DEFAULT_DECK_SIZE = 10; const MAX_DECK_SIZE = 50; @@ -27,8 +42,32 @@ type MatchUserDoc = { matchEnabledAt?: number; }; +// Stored shape of a `hack_cards/{uid}` document (client writes snake_case). +type HackCardDoc = { + username?: string; + discord?: string; + role?: string; + skills?: string[]; + short_bio?: string; + project_interest?: string; + avatar_url?: string | null; +}; + const nowUnixSeconds = (): number => Math.floor(Date.now() / 1000); +// Normalizes a hack-card document into the public profile fields. Returns +// empty defaults when the user has no hack card so the DTO stays consistent. +const mapHackCardProfile = ( + hackCardData: HackCardDoc | null +): HackCardProfile => ({ + username: hackCardData?.username || "", + role: hackCardData?.role || "", + skills: Array.isArray(hackCardData?.skills) ? hackCardData.skills : [], + shortBio: hackCardData?.short_bio || "", + projectInterest: hackCardData?.project_interest || "", + avatarUrl: hackCardData?.avatar_url || "", +}); + const getUidFromRequest = (req: Request): string | null => { if (!req.user?.uid) { return null; @@ -83,6 +122,72 @@ const buildMatchCardFromUser = ( }); }; +const resolveUserName = ( + userData: MatchUserDoc +): { firstName: string; lastName: string } => { + const fallbackName = userData.name || ""; + const fallbackParts = fallbackName.trim().split(/\s+/); + const fallbackFirst = fallbackParts[0] || ""; + const fallbackLast = + fallbackParts.length > 1 ? fallbackParts.slice(1).join(" ") : ""; + + return { + firstName: userData.firstName || fallbackFirst, + lastName: userData.lastName || fallbackLast, + }; +}; + +// Deck/swipe card: identity from `users` enriched with public hack-card +// profile fields. Contact info (discord) is intentionally excluded here. +const buildMatchDeckCard = ( + userId: string, + userData: MatchUserDoc, + hackCardData: HackCardDoc | null +): MatchDeckCardDTO => { + const { firstName, lastName } = resolveUserName(userData); + + return formatMatchDeckCard({ + id: userId, + firstName, + lastName, + school: userData.school || "", + ...mapHackCardProfile(hackCardData), + }); +}; + +// Post-match detail: deck card plus contact info (discord). +const buildMatchDetailFromUser = ( + userId: string, + userData: MatchUserDoc, + hackCardData: HackCardDoc | null +): MatchDetailDTO => { + return formatMatchDetail({ + ...buildMatchDeckCard(userId, userData, hackCardData), + discord: hackCardData?.discord || "", + }); +}; + +// Batch-fetches hack cards for the given user IDs, keyed by UID. Missing +// cards are simply absent from the map (callers default to empty fields). +const getHackCardsByUserId = async ( + userIds: string[] +): Promise> => { + const result = new Map(); + if (userIds.length === 0) { + return result; + } + + const refs = userIds.map((id) => db.collection(HACK_CARDS).doc(id)); + const snapshots = await db.getAll(...refs); + snapshots.forEach((snapshot) => { + if (snapshot.exists) { + result.set(snapshot.id, snapshot.data() as HackCardDoc); + } + }); + + return result; +}; + const getMatchConfig = async (): Promise => { const snapshot = await db.collection(CONFIG).doc(MATCH_CONFIG_DOC).get(); if (!snapshot.exists) { @@ -269,7 +374,7 @@ export const getDeck = async ( db.collection(USERS).where("matchEnabled", "==", true).get(), ]); - const availableCards: MatchCardDTO[] = []; + const eligibleCandidates: { id: string; data: MatchUserDoc }[] = []; optedInUsersSnapshot.docs.forEach((doc) => { const candidateId = doc.id; const candidateData = doc.data() as MatchUserDoc; @@ -286,13 +391,24 @@ export const getDeck = async ( return; } - availableCards.push(buildMatchCardFromUser(candidateId, candidateData)); + eligibleCandidates.push({ id: candidateId, data: candidateData }); }); - const shuffledCards = shuffle(availableCards).slice(0, limit); + const selectedCandidates = shuffle(eligibleCandidates).slice(0, limit); + const hackCards = await getHackCardsByUserId( + selectedCandidates.map((candidate) => candidate.id) + ); + + const deckCards: MatchDeckCardDTO[] = selectedCandidates.map((candidate) => + buildMatchDeckCard( + candidate.id, + candidate.data, + hackCards.get(candidate.id) || null + ) + ); return res.status(200).json({ - data: shuffledCards, + data: deckCards, }); } catch (error) { functions.logger.error(`Error when trying getDeck: ${(error as Error).message}`); @@ -362,6 +478,8 @@ export const swipe = async (req: Request, res: Response): Promise => { if (!isUserMatchCandidate(targetData)) { return res.status(400).json({ error: "Target user is not available" }); } + const currentUserCard = buildMatchCardFromUser(uid, currentUserData); + const targetUserCard = buildMatchCardFromUser(targetId, targetData); const currentTime = nowUnixSeconds(); const rateLimitCutoff = currentTime - 60; @@ -416,11 +534,38 @@ export const swipe = async (req: Request, res: Response): Promise => { matchId = getSortedMatchId(uid, targetId); const matchRef = db.collection(MATCHES).doc(matchId); const sortedUsers = [uid, targetId].sort() as [string, string]; + const notificationCreatedAt = Timestamp.now(); + const notificationForCurrentUser = buildMatchNotification( + uid, + matchId, + targetUserCard, + notificationCreatedAt + ); + const notificationForTargetUser = buildMatchNotification( + targetId, + matchId, + currentUserCard, + notificationCreatedAt + ); + const currentUserNotificationRef = db + .collection(NOTIFICATIONS) + .doc(`${uid}_match_${matchId}`); + const targetUserNotificationRef = db + .collection(NOTIFICATIONS) + .doc(`${targetId}_match_${matchId}`); transaction.set(matchRef, { users: sortedUsers, createdAt: currentTime, } as Match); + transaction.set( + currentUserNotificationRef, + notificationForCurrentUser as Notification + ); + transaction.set( + targetUserNotificationRef, + notificationForTargetUser as Notification + ); matched = true; } }); @@ -528,17 +673,27 @@ export const getMatchById = async ( return res.status(404).json({ error: "Matched user not found" }); } - const otherUserSnapshot = await db.collection(USERS).doc(otherUserId).get(); + const [otherUserSnapshot, otherHackCardSnapshot] = await Promise.all([ + db.collection(USERS).doc(otherUserId).get(), + db.collection(HACK_CARDS).doc(otherUserId).get(), + ]); if (!otherUserSnapshot.exists) { return res.status(404).json({ error: "Matched user not found" }); } const otherUserData = otherUserSnapshot.data() as MatchUserDoc; + const otherHackCardData = otherHackCardSnapshot.exists + ? (otherHackCardSnapshot.data() as HackCardDoc) + : null; return res.status(200).json({ data: { id: matchSnapshot.id, createdAt: matchData.createdAt, - user: buildMatchCardFromUser(otherUserId, otherUserData), + user: buildMatchDetailFromUser( + otherUserId, + otherUserData, + otherHackCardData + ), }, }); } catch (error) { diff --git a/functions/src/models/match.ts b/functions/src/models/match.ts index 7e1b96d..1e339af 100644 --- a/functions/src/models/match.ts +++ b/functions/src/models/match.ts @@ -24,6 +24,27 @@ export interface MatchCardDTO { school: string; } +// Profile fields sourced from the `hack_cards` collection. Surfaced on the +// swipe deck (pre-match) EXCEPT contact info, which is detail-only. +export interface HackCardProfile { + username: string; + role: string; + skills: string[]; + shortBio: string; + projectInterest: string; + avatarUrl: string; +} + +// Deck/swipe card: identity (from `users`) + public hack-card profile. +// Intentionally excludes `discord` since contact info is only revealed +// after a mutual match. +export interface MatchDeckCardDTO extends MatchCardDTO, HackCardProfile {} + +// Post-match detail: everything on the deck card plus contact info. +export interface MatchDetailDTO extends MatchDeckCardDTO { + discord: string; +} + export const formatMatchCard = ( data: Partial & { id?: string } ): MatchCardDTO => ({ @@ -32,3 +53,25 @@ export const formatMatchCard = ( lastName: data.lastName || "", school: data.school || "", }); + +export const formatMatchDeckCard = ( + data: Partial & { id?: string } +): MatchDeckCardDTO => ({ + id: data.id || "", + firstName: data.firstName || "", + lastName: data.lastName || "", + school: data.school || "", + username: data.username || "", + role: data.role || "", + skills: data.skills || [], + shortBio: data.shortBio || "", + projectInterest: data.projectInterest || "", + avatarUrl: data.avatarUrl || "", +}); + +export const formatMatchDetail = ( + data: Partial & { id?: string } +): MatchDetailDTO => ({ + ...formatMatchDeckCard(data), + discord: data.discord || "", +}); diff --git a/functions/src/models/notification.ts b/functions/src/models/notification.ts new file mode 100644 index 0000000..8931997 --- /dev/null +++ b/functions/src/models/notification.ts @@ -0,0 +1,45 @@ +import { Timestamp } from "firebase-admin/firestore"; +import { MatchCardDTO } from "./match"; + +export enum NotificationType { + MATCH = "match", + // TEAM = "team", + // SYSTEM = "system", + // SUBMISSION = "submission", +} + +export interface NotificationData { + matchId: string; + user: MatchCardDTO; +} + +export interface Notification { + id?: string; + userId: string; + type: NotificationType; + title: string; + body: string; + seen: boolean; + refId?: string; + data?: NotificationData; + createdAt: Timestamp; +} + +export const buildMatchNotification = ( + recipientId: string, + matchId: string, + otherUser: MatchCardDTO, + createdAt: Timestamp = Timestamp.now() +): Notification => ({ + userId: recipientId, + type: NotificationType.MATCH, + title: "It's a Match!", + body: `You matched with ${otherUser.firstName} ${otherUser.lastName}`.trim(), + seen: false, + refId: matchId, + data: { + matchId, + user: otherUser, + }, + createdAt, +});