diff --git a/README.matchmaking-client.md b/README.matchmaking-client.md deleted file mode 100644 index a120bfb..0000000 --- a/README.matchmaking-client.md +++ /dev/null @@ -1,299 +0,0 @@ -# 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 d37b7c1..ed3ec38 100644 --- a/README.md +++ b/README.md @@ -105,19 +105,6 @@ 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 35725d7..415027e 100644 --- a/firestore.indexes.json +++ b/firestore.indexes.json @@ -1,51 +1,4 @@ { - "indexes": [ - { - "collectionGroup": "swipes", - "queryScope": "COLLECTION", - "fields": [ - { - "fieldPath": "swiperId", - "order": "ASCENDING" - }, - { - "fieldPath": "createdAt", - "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" - } - ] - } - ], + "indexes": [], "fieldOverrides": [] } diff --git a/firestore.rules b/firestore.rules index 63360ae..472bba9 100644 --- a/firestore.rules +++ b/firestore.rules @@ -47,12 +47,12 @@ service cloud.firestore { } function isGarudaAdmin() { - return request.auth != null && + return request.auth != null && request.auth.token.email != null && request.auth.token.email_verified == true && request.auth.token.email.matches('.*@garudahacks[.]com$'); } - + function verifyTeamMembership(requestingUid, resourceOwnerUid, teamId) { let teamDoc = get(/databases/$(database)/documents/teams/$(teamId)); return teamDoc != null && @@ -61,7 +61,7 @@ service cloud.firestore { requestingUid in teamDoc.data.members && resourceOwnerUid in teamDoc.data.members; } - + function haveSharedTeam(requestingUid, resourceOwnerUid) { let requestingUserDoc = get(/databases/$(database)/documents/users/$(requestingUid)); let resourceOwnerUserDoc = get(/databases/$(database)/documents/users/$(resourceOwnerUid)); @@ -72,34 +72,23 @@ service cloud.firestore { requestingUserDoc.data.team == resourceOwnerUserDoc.data.team && verifyTeamMembership(requestingUid, resourceOwnerUid, requestingUserDoc.data.team); } - + match /applications/{applicationId} { allow read, write: if isGarudaAdmin() || (request.auth != null && request.auth.uid == applicationId); } - - 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 write: if isGarudaAdmin() || + allow write: if isGarudaAdmin() || (request.auth != null && request.auth.uid == userId); - + allow read: if isGarudaAdmin() || (request.auth != null && request.auth.uid == userId) || haveSharedTeam(request.auth.uid, userId); } - - + match /attendances/{attendanceId} { allow read: if isGarudaAdmin() || resource.data.uid == request.auth.uid || haveSharedTeam(request.auth.uid, resource.data.uid); } - + match /announcements/{announcementId} { allow read: if true; allow update: if request.auth != null @@ -108,28 +97,28 @@ service cloud.firestore { && request.resource.data.seen.size() == resource.data.seen.size() + 1 && request.resource.data.seen.hasAll([request.auth.uid]); } - + match /teams/{teamId} { allow write, create, read: if true; allow delete: if request.auth.uid == resource.data.leader; } - + match /config/{configId} { allow read: if true; } - + match /user_mobile/{userId} { allow read, write: if request.auth != null && (request.auth.uid == userId || isGarudaAdmin()); } - + match /schedules/{scheduleId} { allow read: if true; } - + match /hack_cards/{userId} { allow read, write: if request.auth != null && request.auth.uid == userId; } - + match /team_cards/{teamId} { allow write, create, read: if true; } @@ -141,6 +130,6 @@ service cloud.firestore { match /{document=**} { allow read, write: if isGarudaAdmin(); } - + } -} \ No newline at end of file +} \ No newline at end of file diff --git a/functions/package-lock.json b/functions/package-lock.json index c9c9498..489ab82 100644 --- a/functions/package-lock.json +++ b/functions/package-lock.json @@ -18,7 +18,7 @@ "lodash": "^4.17.21", "luxon": "^3.7.1", "nodemailer": "^7.0.3", - "resend": "^6.14.0", + "resend": "^6.12.4", "validator": "^13.15.0" }, "devDependencies": { @@ -600,6 +600,43 @@ "license": "MIT", "peer": true }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.5.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.5.1.tgz", @@ -1650,6 +1687,26 @@ "url": "https://opencollective.com/js-sdsl" } }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", + "integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, "node_modules/@nodable/entities": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz", @@ -1711,6 +1768,18 @@ "node": ">=8.0.0" } }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=14" + } + }, "node_modules/@pkgr/core": { "version": "0.3.6", "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.3.6.tgz", @@ -1835,6 +1904,18 @@ "node": ">= 10" } }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -2383,6 +2464,371 @@ "dev": true, "license": "ISC" }, + "node_modules/@unrs/resolver-binding-android-arm-eabi": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.12.2.tgz", + "integrity": "sha512-g5T90pqg1bo/7mytQx6F4iBNC0Wsh9cu+z9veDbFjc7HjpesJFWD7QMS0NGStXM075+7dJPPVvBbpZlnrdpi/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true + }, + "node_modules/@unrs/resolver-binding-android-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.12.2.tgz", + "integrity": "sha512-YGCRZv/9GLhwmz6mYDeTsm/92BAyR28l6c2ReweVW5pWgfsitWLY8upvfRlGdoyD8HjeTHSYJWyZGD4KJA/nFQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true + }, + "node_modules/@unrs/resolver-binding-darwin-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.12.2.tgz", + "integrity": "sha512-u9DiNT1auQMO20A9SyTuG3wUgQWB9Z7KjAg0uFuCDR1FsAY8A0CG2S6JpHS1xwm/w1G08bjXZDcyOCjv1WAm2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true + }, + "node_modules/@unrs/resolver-binding-darwin-x64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.12.2.tgz", + "integrity": "sha512-f7rPLi/T1HVKZu/u6t87lroib16n8vrSzcyxI7lg4BGO9UF26KhQL44sd9eOUgrTYhvRXtWOIZT5PejdPyJfUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true + }, + "node_modules/@unrs/resolver-binding-freebsd-x64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.12.2.tgz", + "integrity": "sha512-BpcOjWCJub6nRZUS2zA20pmLvjtqAtGejETaIyRLiZiQf++cbrjltLA5NN/xaXfqeOBOSlMFbemIl5/S5tljmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true + }, + "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.12.2.tgz", + "integrity": "sha512-vZTDvdSISZjJx66OzJqtsOhzifbqRjbmI1Mnu49fQDwog5GtDI4QidRiEAYbZCRj9C8YZEW+3ZjqsyS9GR4k2A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.12.2.tgz", + "integrity": "sha512-BiPI+IrIlwcW4nLLMM21+B1dFPzd55yAVgVGrdgDjNef+ch03GdxrcyaIz8X9SsQirh/kCQ7mviyWlMxdh2D7g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.12.2.tgz", + "integrity": "sha512-zJc0H99FEPoFfSrNpa91HYfxzfAJCr502oxNK1cfdC9hlaFI43RT+JFCann9JUgZmLzzntChHyn13Sgn9ljHNg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/@unrs/resolver-binding-linux-arm64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.12.2.tgz", + "integrity": "sha512-KQ3Lki6l+Pz1k/eBipN41ES+YUK30beLGb9YqcB1O542cyLCNE6GaxrfcY3T6EezmGGk84wb5XyO9loTM9tkcA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/@unrs/resolver-binding-linux-loong64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-loong64-gnu/-/resolver-binding-linux-loong64-gnu-1.12.2.tgz", + "integrity": "sha512-3SJGEh1DborhG6pyxvhPzCT4bbSIVihsvgJc13P1bHG7KLdNDaF9T3gsTwFc7Jw/5Y5/iWOjkEx7Zy0NvCGX3Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/@unrs/resolver-binding-linux-loong64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-loong64-musl/-/resolver-binding-linux-loong64-musl-1.12.2.tgz", + "integrity": "sha512-jiuG/Obbel7uw1PwHNFfrkiKhLAF6mnyZ6aWlOAVN9WqKm8v0OFGnciJIHu8+CMvXLQ8AD51LPzAoUfT21D5Ew==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.12.2.tgz", + "integrity": "sha512-q7xRvVpmcfeL+LlZg8Pbbo6QaTZwDU5BaGZbwfhkEsXJn3Was8xYfE0RBH266xZt0rM6B7i8xAYIvjthuUIWHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.12.2.tgz", + "integrity": "sha512-0CVdx6lcnT3Q9inOH8tsMIOJ6ImndllMjqJHg8RLVdB7Vq4SfkEXl9mCSsVNuNA4MCYycRicCUxPCabVHJRr6A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.12.2.tgz", + "integrity": "sha512-iOwlRo9vnp6R6ohHQS11n0NnfdXx/omhkocmIfaPRpQhKZ+3BDMkkdRVh53qjkFkpPddf+FETA28NwGN7l5l+w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.12.2.tgz", + "integrity": "sha512-HYJtLfXq94q8iZNFT1lknx258wlkkWhZeUXJRqzKBBUJ00CvZ+N33zgbCqimLjsyw5Va6uUxhVa12mI+kaveEw==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/@unrs/resolver-binding-linux-x64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.12.2.tgz", + "integrity": "sha512-mPsUhunKKDih5O96Y6enDQyHc1SqBPlY1E/SfMWDM3EdJ95Z9CArPeCVwCCqbP45ljvivdEk8Fxn+SIb1rDAJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/@unrs/resolver-binding-linux-x64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.12.2.tgz", + "integrity": "sha512-azrt6+5ydLd8Vt210AAFis/lZevSfPw93EJRIJG+xPu4WCJ8K0kppCTpMyLPcKT7H15M4Jnt2tMp5bOvCkRC6A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/@unrs/resolver-binding-openharmony-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-openharmony-arm64/-/resolver-binding-openharmony-arm64-1.12.2.tgz", + "integrity": "sha512-YZ9hP4O0X9PQb8eO980qmLNGH4zT3I9+SZTdt0Pr0YyuGQhYKoOZkV02VzrzyOZJ5xIJ3UFIenKkUkGg8GjgWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "peer": true + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.12.2.tgz", + "integrity": "sha512-tYFDIkMxSflfEc/h92ZWNsZlHSwgimbNHSO3PL2JWQHfCuC2q316jMyYU9TIWZsFK2bQwyK5VAdYgn8ygPj69A==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.12.2.tgz", + "integrity": "sha512-qzNyg3xL0VPQmCaUh+N5jSitce6k+uCBfMDesWRnlULOZaqUkaJ0ybdT+UqlAWJoQjuqfIU/0Ptx9bteN4D82g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true + }, + "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.12.2.tgz", + "integrity": "sha512-WD9sY00OfpHVGfsnHZoA8jVT+esS/Bg8z8jzxp5BnDCjjwsuKsPQrzswwpFy4J1AUJbXPRfkpcX0mXrzeXW79g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true + }, + "node_modules/@unrs/resolver-binding-win32-x64-msvc": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.12.2.tgz", + "integrity": "sha512-nAB74NfSNKknqQ1RrYj6uz8FcXEomu/MATJZxh/x+BArzN2U3JbOYC0APYzUIGhVY3m5hRxA8VPNdPBoG8txlA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true + }, "node_modules/abort-controller": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", @@ -4897,6 +5343,22 @@ "dev": true, "license": "ISC" }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -8386,9 +8848,9 @@ } }, "node_modules/resend": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/resend/-/resend-6.14.0.tgz", - "integrity": "sha512-jVdpUgOoWGLjaP64lo8KwzHT9gY4w6Dl8c36CIb2F+ayYOMLr3khqs8xrNjXM2k19b+lPoj0VWQFhVNLiToBjA==", + "version": "6.12.4", + "resolved": "https://registry.npmjs.org/resend/-/resend-6.12.4.tgz", + "integrity": "sha512-lRpJ2Hxd+ht+JPDm97juRcUp9HOMuZyxaRFRFmc9Tx8iNWiei94Dx9v6SWufgKk2667C/uCeKKspMotOHSpCSg==", "license": "MIT", "dependencies": { "postal-mime": "2.7.4", diff --git a/functions/package.json b/functions/package.json index 812a475..4d3e03a 100644 --- a/functions/package.json +++ b/functions/package.json @@ -5,7 +5,7 @@ "lint": "eslint --ext .js,.ts .", "build": "tsc", "build:watch": "tsc --watch --preserveWatchOutput", - "serve": "concurrently \"npm:build:watch\" \"firebase emulators:start --only functions\"", + "serve": "concurrently \"npm:build:watch\" \"firebase emulators:start\"", "shell": "npm run build && firebase functions:shell", "start": "npm run shell", "deploy": "firebase deploy --only functions", @@ -28,7 +28,7 @@ "lodash": "^4.17.21", "luxon": "^3.7.1", "nodemailer": "^7.0.3", - "resend": "^6.14.0", + "resend": "^6.12.4", "validator": "^13.15.0" }, "devDependencies": { @@ -53,6 +53,5 @@ "firebase-functions-test": "^3.1.0", "typescript": "^5.8.2" }, - "private": true, - "packageManager": "npm@11.12.1+sha512.cdca14b85d647b3192028d02aadbe82d75f79a446aceea9874be98e6d768f20ebd3555770a48d0e9906106007877bbc690f715e9372f2e2dc644a3c3157fb14c" + "private": true } diff --git a/functions/src/controllers/match_controller.ts b/functions/src/controllers/match_controller.ts deleted file mode 100644 index 64e9dd8..0000000 --- a/functions/src/controllers/match_controller.ts +++ /dev/null @@ -1,1793 +0,0 @@ -import {Request, Response} from "express"; -import * as functions from "firebase-functions"; -import {db} from "../config/firebase"; -import {APPLICATION_STATUS} from "../types/application_types"; -import {MatchConfig} from "../types/config"; -import { - formatMatchCard, - formatMatchDeckCard, HackCardDoc, - // formatMatchDetail, - HackCardProfile, Match, MatchCardDTO, - MatchDeckCardDTO, - MatchUserDoc, - // MatchDetailDTO, - Swipe, - SwipeDirection, TeamCardDoc, TeamDoc -} from "../models/match"; -import {buildMatchNotification, Notification as MatchNotification, NotificationType} from "../models/notification"; -import {Timestamp} from "firebase-admin/firestore"; - -const USERS = "users"; -const CONFIG = "config"; -const MATCH_CONFIG_DOC = "matchConfig"; -const TEAMS = "teams"; -const MAX_TEAM_SIZE = 4; -const DEFAULT_DECK_SIZE = 10; -const MAX_DECK_SIZE = 50; -const SWIPES = "swipes"; -const HACK_CARDS = "hack_cards"; -const NOTIFICATIONS = "notifications"; -const RATE_LIMIT_PER_MINUTE = 15; -const MATCHES = "matches"; -const TEAM_SWIPES = "team_swipes"; -const TEAM_CARDS = "team_cards"; - -const DISCORD_BOT_TOKEN = process.env.DISCORD_BOT_TOKEN ?? ""; -const DISCORD_GUILD_ID = process.env.DISCORD_GUILD_ID ?? ""; -const DISCORD_CATEGORY_NAME = "Speed Dating"; - -// VIEW_CHANNEL + SEND_MESSAGES + READ_MESSAGE_HISTORY -const DISCORD_ALLOW_PERMS = "68608"; -const DISCORD_DENY_PERMS = "68608"; - -// In-memory cache so we only fetch/create the category once per cold start -let cachedCategoryId: string | null = null; - -/** - * Returns the Speed Dating category ID, creating it if it doesn't exist. - */ -const getOrCreateSpeedDatingCategory = async (): Promise => { - if (cachedCategoryId) return cachedCategoryId; - - try { - // Fetch all channels in the guild - const res = await fetch(`https://discord.com/api/v10/guilds/${DISCORD_GUILD_ID}/channels`, { - headers: {Authorization: `Bot ${DISCORD_BOT_TOKEN}`}, - }); - - if (!res.ok) return null; - - const channels = await res.json() as {id: string; type: number; name: string}[]; - - // Check if category already exists (type 4 = GUILD_CATEGORY) - const existing = channels.find( - (c) => c.type === 4 && c.name === DISCORD_CATEGORY_NAME - ); - - if (existing) { - cachedCategoryId = existing.id; - return cachedCategoryId; - } - - // Create it if not found - const createRes = await fetch(`https://discord.com/api/v10/guilds/${DISCORD_GUILD_ID}/channels`, { - method: "POST", - headers: { - Authorization: `Bot ${DISCORD_BOT_TOKEN}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - name: DISCORD_CATEGORY_NAME, - type: 4, - }), - }); - - if (!createRes.ok) { - functions.logger.error(`Failed to create category: ${await createRes.text()}`); - return null; - } - - const category = await createRes.json() as {id: string}; - cachedCategoryId = category.id; - return cachedCategoryId; - } catch (err) { - functions.logger.error(`Category error: ${(err as Error).message}`); - return null; - } -}; - -/** - * Creates a private Discord text channel visible only to the given user IDs. - * Returns the channel URL, or null if creation fails or config is missing. - */ -const createDiscordPrivateChannel = async ( - channelName: string, - discordUserIds: string[] -): Promise => { - if (!DISCORD_BOT_TOKEN || !DISCORD_GUILD_ID) { - functions.logger.warn("DISCORD_BOT_TOKEN or DISCORD_GUILD_ID not set — skipping channel creation"); - return null; - } - - try { - const [categoryId, permissionOverwrites] = await Promise.all([ - getOrCreateSpeedDatingCategory(), - Promise.resolve([ - // Deny @everyone - {id: DISCORD_GUILD_ID, type: 0, deny: DISCORD_DENY_PERMS, allow: "0"}, - // Allow each matched user - ...discordUserIds.map((userId) => ({ - id: userId, - type: 1, - allow: DISCORD_ALLOW_PERMS, - deny: "0", - })), - ]), - ]); - - const createRes = await fetch( - `https://discord.com/api/v10/guilds/${DISCORD_GUILD_ID}/channels`, - { - method: "POST", - headers: { - Authorization: `Bot ${DISCORD_BOT_TOKEN}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - name: channelName, - type: 0, // GUILD_TEXT - permission_overwrites: permissionOverwrites, - ...(categoryId ? {parent_id: categoryId} : {}), - }), - } - ); - - if (!createRes.ok) { - const err = await createRes.text(); - functions.logger.error(`Discord channel creation failed: ${err}`); - return null; - } - - const channel = await createRes.json() as {id: string}; - return `https://discord.com/channels/${DISCORD_GUILD_ID}/${channel.id}`; - } catch (err) { - functions.logger.error(`Discord channel creation error: ${(err as Error).message}`); - return null; - } -}; - -/** - * Sends a message to a Discord channel. - * channelUrl format: https://discord.com/channels/{guildId}/{channelId} - */ -const sendDiscordChannelMessage = async (channelUrl: string, message: string): Promise => { - if (!DISCORD_BOT_TOKEN) return; - - const channelId = channelUrl.split("/").pop(); - if (!channelId) return; - - try { - const res = await fetch(`https://discord.com/api/v10/channels/${channelId}/messages`, { - method: "POST", - headers: { - Authorization: `Bot ${DISCORD_BOT_TOKEN}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({content: message}), - }); - - if (!res.ok) { - functions.logger.error(`Failed to send channel message: ${await res.text()}`); - } - } catch (err) { - functions.logger.error(`Discord channel message error: ${(err as Error).message}`); - } -}; - -const buildIndividualMatchMessage = ( - discordUid1: string, - discordUid2: string -): string => `🎉 **It's a Match!** 🎉 - -Hey <@${discordUid1}> & <@${discordUid2}>! You just matched on **GarudaHacks Speed Dating**! Time to find your dream team. 🚀 - -───────────────────────── -**Here's what to do next:** -───────────────────────── - -**① 💬 Say Hi!** -> Introduce yourselves right here in this channel! - -**② 🤝 Get to Know Each Other** -> Share your skills, ideas, and what kind of project you want to build. - -**③ 🏗️ Form Your Team** -> If you vibe, one of you creates a team on the **GarudaHacks app**. -> The team leader will get a **Team Code** — share it here! - -**④ 📨 Share the Invite Code** -> If you think they're a good match, send your **Team Invitation Code** in this channel so your match can join! - -**⑤ ✅ Join the Team** -> Enter the Team Code on the **GarudaHacks app** to officially join. - -───────────────────────── -Good luck and happy hacking! 💪⚡`; - -const buildTeamMatchMessage = ( - individualDiscordUid: string, - leaderDiscordUid: string, - individualName: string, - teamName: string -): string => `🎉 **New Team Member!** 🎉 - -Hey <@${leaderDiscordUid}> & <@${individualDiscordUid}>! **${individualName}** just joined team **${teamName}** on **GarudaHacks Speed Dating**! 🚀 - -───────────────────────── -**Here's what to do next:** -───────────────────────── - -**① 💬 Say Hello!** -> Welcome your new teammate right here in this channel! - -**② 🤝 Get Everyone on the Same Page** -> Share your project idea, tech stack, and roles with each other. - -**③ 📨 Share the Invite Code** -> If you think they're a good match, send your **Team Invitation Code** in this channel so they can join via the **GarudaHacks app**! - -**④ ✅ Confirm on App** -> Check the **GarudaHacks app** to see your updated team roster. - -───────────────────────── -Good luck and happy hacking! 💪⚡`; - - -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 nowUnixSeconds = (): number => Math.floor(Date.now() / 1000); - - -const getUidFromRequest = (req: Request): string | null => { - if (!req.user?.uid) { - return null; - } - return req.user.uid; -}; - - -const isUserEligibleForOptIn = (userData: MatchUserDoc): boolean => { - return ( - userData.status === APPLICATION_STATUS.CONFIRMED_RSVP && - userData.mentor !== true && - userData.admin !== true - ); -}; - -const isMatchOpen = async (): Promise => { - const config = await getMatchConfig(); - if (!config) { - return false; - } - return config.isMatchOpen === true; -}; - - -const getSortedMatchId = (uidA: string, uidB: string): string => { - return [uidA, uidB].sort().join("_"); -}; - - -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; -}; - -// 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); - } - }); - - console.log(result) - return result; -}; - -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 isUserOptedIn = (userData: MatchUserDoc): boolean => { - return userData.match_enabled === 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, - }); -}; - -// // 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 || "", -// }); -// }; - -// Adds individual to the team, notifies both parties. -const handleTeamMatch = async ( - individualUid: string, - teamId: string, - teamData: TeamDoc, - individualUserData: MatchUserDoc -): Promise => { - const leaderId = teamData.leader ?? ""; - const now = Timestamp.now(); - const matchId = `${teamId}_${individualUid}`; - const sortedUsers = [leaderId, individualUid].sort() as [string, string]; - const teamName = teamData.name ?? ""; - - // Build cards for notification data - const individualCard = buildMatchCardFromUser(individualUid, individualUserData); - - // Fetch leader's discord_uid for private channel creation - const leaderSnap = leaderId ? await db.collection(USERS).doc(leaderId).get() : null; - const leaderData = leaderSnap?.exists ? (leaderSnap.data() as MatchUserDoc) : null; - - const discordUserIds = [ - individualUserData.discord_uid, - leaderData?.discord_uid, - ].filter((id): id is string => !!id); - - const sanitize = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, "-").replace(/-+/g, "-").slice(0, 20); - const channelName = `match-${sanitize(teamName)}-${sanitize(individualCard.firstName)}`; - const discordChannelUrl = discordUserIds.length >= 2 - ? await createDiscordPrivateChannel(channelName, discordUserIds) - : null; - - const individualName = `${individualCard.firstName} ${individualCard.lastName}`.trim(); - - await Promise.all([ - db.collection(MATCHES).doc(matchId).set({ - id: matchId, - users: sortedUsers, - type: "team", - teamId, - createdAt: Math.floor(now.toMillis() / 1000), - ...(discordChannelUrl ? {discordChannelUrl} : {}), - } as Match & { id: string; discordChannelUrl?: string }), - - // Individual gets notified: "You matched with team {teamName}" - db.collection(NOTIFICATIONS).doc(`${individualUid}_match_${matchId}`).set({ - userId: individualUid, - type: NotificationType.MATCH, - title: "It's a Match!", - body: `You matched with team ${teamName}. Say hi!`, - seen: false, - refId: matchId, - data: {matchId, user: individualCard, discordChannelUrl}, - createdAt: now, - } as MatchNotification), - - // Leader gets notified: "You matched with {individualName}" - leaderId - ? db.collection(NOTIFICATIONS).doc(`${leaderId}_match_${matchId}`).set({ - userId: leaderId, - type: NotificationType.MATCH, - title: "It's a Match!", - body: `You matched with ${individualName}`.trim(), - seen: false, - refId: matchId, - data: {matchId, user: individualCard, discordChannelUrl}, - createdAt: now, - } as MatchNotification) - : Promise.resolve(), - - // Send message to private channel tagging both users - discordChannelUrl && individualUserData.discord_uid && leaderData?.discord_uid - ? sendDiscordChannelMessage( - discordChannelUrl, - buildTeamMatchMessage( - individualUserData.discord_uid, - leaderData.discord_uid, - individualName, - teamName - ) - ) - : Promise.resolve(), - ]); - - return matchId; -}; - - -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 isUserMatchCandidate = (userData: MatchUserDoc): boolean => { - return isUserOptedIn(userData) && userData.mentor !== true && userData.admin !== true; -}; - - -const getUserTeamStatus = async (uid: string): Promise<{ inTeam: boolean; isLeader: boolean; teamFull: boolean }> => { - const snapshot = await db - .collection(TEAMS) - .where("members", "array-contains", uid) - .limit(1) - .get(); - - if (snapshot.empty) { - return {inTeam: false, isLeader: false, teamFull: false}; - } - - const team = snapshot.docs[0].data(); - const memberCount = Array.isArray(team.members) ? team.members.length : 0; - return { - inTeam: true, - isLeader: team.leader === uid, - teamFull: memberCount >= MAX_TEAM_SIZE, - }; -}; - -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, - discordUsername = "" -): MatchDeckCardDTO => { - const {firstName, lastName} = resolveUserName(userData); - - return formatMatchDeckCard({ - id: userId, - firstName, - lastName, - discord_uid: userData.discord_uid ?? "", - discord_username: discordUsername, - ...mapHackCardProfile(hackCardData), - }); -}; - -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 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; -}; - -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}); - } -}; - -const isValidSwipeDirection = (direction: string): direction is SwipeDirection => { - return ( - direction === SwipeDirection.LEFT || direction === SwipeDirection.RIGHT - ); -}; - -export const getMatchStatus = async ( - req: Request, - res: Response -): Promise => { - try { - - // get the user's uid from the request (session login) - const uid = getUidFromRequest(req); - if (!uid) { - return res.status(401).json({error: "Unauthorized"}); - } - - // get current user's data based on the uid - const {userData, exists} = await getCurrentUserDoc(uid); - - // if user data is not found, return 404 - if (!exists || !userData) { - return res.status(404).json({error: "User not found"}); - } - - // get users whether they are inTeam, isLeader, and teamFull based on the uid - const {inTeam, isLeader, teamFull} = await getUserTeamStatus(uid); - - // scenarios - // user without no team show team and solo deck - // user with team and not leader don't show anything about speed dating V - // user with team and leader only show solo deck - // user status / eligible is not applicable, it shows "the user is not eligible for opt in / speed dating" - // user optedIn is false, it shows the button to enable opt in - - return res.status(200).json({ - data: { - optedIn: isUserOptedIn(userData), - eligible: isUserEligibleForOptIn(userData), - isDiscordConnected: !(userData.discord_uid == null || userData.discord_uid === ""), - inTeam, - isLeader, - teamFull, - }, - }); - } 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 { - - // get the user's uid from the request (session login) - const uid = getUidFromRequest(req); - if (!uid) { - return res.status(401).json({error: "Unauthorized"}); - } - - // get current user's data based on the uid - const {userData, exists} = await getCurrentUserDoc(uid); - - // if user data is not found, return 404 - if (!exists || !userData) { - return res.status(404).json({error: "User not found"}); - } - - - // users already opted in, so they are eligible to continue to get the deck function - if (isUserOptedIn(userData)) { - return res.status(200).json({ - message: "You are already opted in", - }); - } - - // it shows the button to enable opt in - if (!isUserEligibleForOptIn(userData)) { - return res.status(403).json({ - error: "You are not eligible to opt in", - }); - } - - // get users whether they are inTeam, isLeader, and teamFull based on the uid - const {inTeam, isLeader, teamFull} = await getUserTeamStatus(uid); - // Block if the user is in a team, unless they are a leader with an open slot. - // Regular members and leaders of full teams cannot access the deck. - if (inTeam && !(isLeader && !teamFull)) { - return res.status(403).json({ - error: "You are already in a team", - }); - } - - // update the user's matchEnabled field to true to continue to get the deck function - await db.collection(USERS).doc(uid).set( - { - match_enabled: true, - }, - {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 { - - // get the user's uid from the request (session login) - const uid = getUidFromRequest(req); - if (!uid) { - return res.status(401).json({error: "Unauthorized"}); - } - - // get current user's data based on the uid - const {userData, exists} = await getCurrentUserDoc(uid); - if (!exists || !userData) { - return res.status(404).json({error: "User not found"}); - } - - // if user is not opted in, show text in mobile "You must opt in first" - if (!isUserOptedIn(userData)) { - return res.status(403).json({error: "You must opt in first"}); - } - - // get users whether they are inTeam, isLeader, and teamFull based on the uid - const {inTeam, isLeader, teamFull} = await getUserTeamStatus(uid); - - - // Block if the user is in a team, unless they are a leader with an open slot. - // Regular members and leaders of full teams cannot access the deck. - if (inTeam && !(isLeader && !teamFull)) { - return res.status(403).json({error: "You are already in a team"}); - } - - const matchOpen = await isMatchOpen(); - if (!matchOpen) { - return res.status(403).json({error: "Matchmaking is currently closed"}); - } - - // it determines the number of deck cards to show based on the limit query parameter - const limit = parseDeckLimit(req.query.limit); - - - // basically, just take three collections: swipedTargetIds, optedInUsersSnapshot, and teamsSnapshot - const [swipedTargetIds, optedInUsersSnapshot, teamsSnapshot] = await Promise.all([ - getSwipedTargetIds(uid), - db.collection(USERS).where("match_enabled", "==", true).get(), - db.collection(TEAMS).get(), - ]); - - // Build a set of all UIDs that belong to any team (leader or member) - // This set is then used later to exclude these UIDs from the swipe deck — so users who are already in a team won't appear as candidates. - /* - usersInTeam = Set { - "SKyN0Cj9gpWLMNmxjcwHl9z68zh2", // 5ZPMHC member - "Rk9mP2vXnL4QwZ8jYcT6hB3eA7sN", // 5ZPMHC member - "Ys7nB4eXkP2mT9wQ5hR1vF6jC8dA", // F8FSVF member - "Lc3gK7hN5tX8qB2mE4vJ9fW1pR6s", // F8FSVF member - "QHed6r1dlyMq9xRWdNAYoRGjHij1", // 2B6LKD member - "PHtgki6o4UhtEjrHAUOkYgJMR1T2", // 2B6LKD member - "98srcveqjnMdZdyEyct7csaxyGN2", // 2B6LKD member - } - */ - const usersInTeam = new Set(); - - teamsSnapshot.docs.forEach((doc) => { - - const team = doc.data(); - - if (Array.isArray(team.members)) { - team.members.forEach((memberId: string) => usersInTeam.add(memberId)); - } - - }); - - - // eligibleCandidates that are going to be shown in the deck - const eligibleCandidates: { id: string; data: MatchUserDoc }[] = []; - - // optedInUsersSnapshot shows all users who have matchEnabled == true - optedInUsersSnapshot.docs.forEach((doc) => { - const candidateId = doc.id; - const candidateData = doc.data() as MatchUserDoc; - - // if the candidate is the same as the user, skip it - if (candidateId === uid) { - return; - } - - // if current session login or user already swipe candidate or target id, so there is no same candidate appears twice in the deck - if (swipedTargetIds.has(candidateId)) { - return; - } - - // shows only matchEnable == true, mentor == false, admin == false - if (!isUserMatchCandidate(candidateData)) { - return; - } - - // Only show solo users — exclude anyone in a team (leader or member) - if (usersInTeam.has(candidateId)) { - return; - } - - eligibleCandidates.push({id: candidateId, data: candidateData}); - }); - - // shuffle eligibleCandidates - const selectedCandidates = shuffle(eligibleCandidates).slice(0, limit); - - // Batch-fetch hack_cards for all selected candidates, keyed by UID. - // Example result: - // Map { - // "Rk9mP2vXnL4QwZ8jYcT6hB3eA7sN" => { - // uid: "", username: "aisha_dev", discord: "aisha_dev#1001", - // role: "Frontend", skills: ["React", "TypeScript", "Figma"], - // short_bio: "I love building beautiful UIs...", - // project_interest: "Social impact apps", - // avatar_url: "https://i.pravatar.cc/150?u=U1", created_at: ... - // }, - // "Ys7nB4eXkP2mT9wQ5hR1vF6jC8dA" => { - // uid: "", username: "citra_ml", discord: "citra_ml#1003", - // role: "ML/AI", skills: ["Python", "TensorFlow", "PyTorch"], - // short_bio: "Passionate about making AI explainable and fair.", - // project_interest: "Healthcare AI", - // avatar_url: "https://i.pravatar.cc/150?u=U3", created_at: ... - // } - // } - const hackCards = await getHackCardsByUserId( - selectedCandidates.map((candidate) => candidate.id) - ); - - // Fetch Discord usernames in parallel, deduplicated by discord_uid - const allDiscordUids = new Set( - selectedCandidates - .map((c) => c.data.discord_uid) - .filter((id): id is string => id != null) - ); - const discordUsernameMap = new Map(); - await Promise.all( - [...allDiscordUids].map(async (discordUid) => { - const discordRes = await fetch(`https://elaina.garudahacks.com/api/user/${discordUid}`); - const data = await discordRes.json(); - discordUsernameMap.set(discordUid, data["username"] ?? ""); - }) - ); - - const deckCards: MatchDeckCardDTO[] = selectedCandidates.map((candidate) => - buildMatchDeckCard( - candidate.id, - candidate.data, - hackCards.get(candidate.id) || null, - candidate.data.discord_uid - ? (discordUsernameMap.get(candidate.data.discord_uid) ?? "") - : "" - ) - ); - - - console.log(deckCards); - console.log(deckCards[0]); - - return res.status(200).json({ - data: deckCards, - }); - } catch (error) { - functions.logger.error(`Error when trying getDeck: ${(error as Error).message}`); - return res.status(500).json({error: (error as Error).message}); - } -}; - - -export const swipe = async (req: Request, res: Response): Promise => { - try { - - // get the user's uid from the request (session login) - const uid = getUidFromRequest(req); - if (!uid) { - return res.status(401).json({error: "Unauthorized"}); - } - - // get current user's data based on the uid - const {userData: currentUserData, exists: currentUserExists} = - await getCurrentUserDoc(uid); - if (!currentUserExists || !currentUserData) { - return res.status(404).json({error: "User not found"}); - } - - // if user is not opted in, show text in mobile "You must opt in first" - if (!isUserOptedIn(currentUserData)) { - return res.status(403).json({error: "You must opt in first"}); - } - - // get users whether they are inTeam, isLeader, and teamFull based on the uid - const {inTeam, isLeader, teamFull} = await getUserTeamStatus(uid); - - - // Block if the user is in a team, unless they are a leader with an open slot. - // Regular members and leaders of full teams cannot access the deck. - if (inTeam && !(isLeader && !teamFull)) { - return res.status(403).json({error: "You are already in a team"}); - } - - const matchOpen = await isMatchOpen(); - if (!matchOpen) { - return res.status(403).json({error: "Matchmaking is currently closed"}); - } - - // request body - const {targetId, direction} = req.body as { - targetId?: string; - direction?: string; - }; - - // if targetId is not provided, return 400 - if (!targetId || !direction) { - return res.status(400).json({ - error: "targetId and direction are required", - }); - } - - // if targetId is not valid direction, return 400 - if (!isValidSwipeDirection(direction)) { - return res.status(400).json({ - error: "direction must be either 'left' or 'right'", - }); - } - - // if he swiped himself, return 400 - if (targetId === uid) { - return res.status(400).json({error: "You cannot swipe yourself"}); - } - - // if target user does not exist, return 400 - const targetSnapshot = await db.collection(USERS).doc(targetId).get(); - if (!targetSnapshot.exists) { - return res.status(400).json({error: "Invalid target user"}); - } - - // if matchEnable == true, mentor == false, admin == false -> target user is forbidden to access the deck - const targetData = targetSnapshot.data() as MatchUserDoc; - if (!isUserMatchCandidate(targetData)) { - return res.status(400).json({error: "Target user is not available"}); - } - - // { - // id: "Rk9mP2vXnL4QwZ8jYcT6hB3eA7sN", - // firstName: "Aisha", - // lastName: "Rahmawati", - // } - const currentUserCard = buildMatchCardFromUser(uid, currentUserData); - - // { - // id: "Rk9mP2vXnL4QwZ8jYcT6hB3eA7sN", - // firstName: "Aisha", - // lastName: "Rahmawati", - // } - const targetUserCard = buildMatchCardFromUser(targetId, targetData); - - // currentime - const currentTime = nowUnixSeconds(); - const rateLimitCutoff = currentTime - 60; - - - // current card that user swiped - const recentSwipeSnapshot = await db - .collection(SWIPES) - .where("swiperId", "==", uid) - .where("createdAt", ">", rateLimitCutoff) - .orderBy("createdAt", "desc") - .limit(RATE_LIMIT_PER_MINUTE) - .get(); - - // if user swiped too many times, return 429 - if (recentSwipeSnapshot.size >= RATE_LIMIT_PER_MINUTE) { - return res.status(429).json({ - error: "Too many swipes. Please try again shortly.", - }); - } - - // Swipe doc IDs follow the pattern `{swiperId}_{targetId}`. - // ownSwipeDocId = current user's swipe on the target. - // reciprocalSwipeDocId = target's swipe on the current user (used to detect a mutual match). - 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; - - // Use a Firestore transaction to guarantee atomicity: - // reading and writing happen as one unit so no two users can - // create duplicate swipes or matches at the same time. - await db.runTransaction(async (transaction) => { - - // ALL READS FIRST (Firestore transaction requirement) - // 1. Read own swipe + reciprocal swipe in parallel - const [ownSwipeSnapshot, reciprocalSwipeSnapshot] = await Promise.all([ - transaction.get(ownSwipeRef), - transaction.get(reciprocalSwipeRef), - ]); - - // Validate: reject duplicate RIGHT swipes, allow LEFT → RIGHT overwrite - if (ownSwipeSnapshot.exists) { - const existingDirection = (ownSwipeSnapshot.data() as Swipe).direction; - if (existingDirection === SwipeDirection.RIGHT) { - throw new Error("ALREADY_SWIPED"); - } - } - - // 2. Determine reciprocal match eligibility - let reciprocalSwipeData: Swipe | null = null; - if (direction === SwipeDirection.RIGHT && reciprocalSwipeSnapshot.exists) { - reciprocalSwipeData = reciprocalSwipeSnapshot.data() as Swipe; - } - - // ALL WRITES AFTER ALL READS - // 3. Delete old left swipe if overwriting - if (ownSwipeSnapshot.exists) { - transaction.delete(ownSwipeRef); - } - - // 3. Save the current user's swipe to Firestore. - transaction.set(ownSwipeRef, { - swiperId: uid, - targetId, - direction, - createdAt: currentTime, - } as Swipe); - - // 4. If both users swiped RIGHT → it's a match! - // Create the match doc and notify both users. - if ( - direction === SwipeDirection.RIGHT && - reciprocalSwipeData && - reciprocalSwipeData.direction === SwipeDirection.RIGHT - ) { - // matchId is sorted so both users always produce the same doc ID - // e.g. "Lc3gK7..._Rk9mP2..." regardless of who swiped first. - matchId = getSortedMatchId(uid, targetId); - const matchRef = db.collection(MATCHES).doc(matchId); - const sortedUsers = [uid, targetId].sort() as [string, string]; - const notificationCreatedAt = Timestamp.now(); - - // Each user gets notified with the other person's card. - // currentUser notification shows targetUserCard (who they matched with). - // targetUser notification shows currentUserCard (who matched them). - 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}`); - - // Write match doc and both notifications atomically. - transaction.set(matchRef, { - users: sortedUsers, - type: "individual", - createdAt: currentTime, - } as Match); - transaction.set( - currentUserNotificationRef, - notificationForCurrentUser as MatchNotification - ); - transaction.set( - targetUserNotificationRef, - notificationForTargetUser as MatchNotification - ); - matched = true; - } - }); - - // Create a private Discord channel + send DMs for the matched pair - let discordChannelUrl: string | null = null; - if (matched && matchId) { - const resolvedMatchId = matchId as string; - const discordUserIds = [currentUserData.discord_uid, targetData.discord_uid] - .filter((id): id is string => !!id); - - if (discordUserIds.length === 2) { - const sanitize = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, "-").replace(/-+/g, "-").slice(0, 20); - const channelName = `match-${sanitize(currentUserCard.firstName)}-${sanitize(targetUserCard.firstName)}`; - discordChannelUrl = await createDiscordPrivateChannel(channelName, discordUserIds); - - if (discordChannelUrl) { - await db.collection(MATCHES).doc(resolvedMatchId).update({discordChannelUrl}); - } - } - - // Send message to private channel tagging both users - if (discordChannelUrl && currentUserData.discord_uid && targetData.discord_uid) { - await sendDiscordChannelMessage( - discordChannelUrl, - buildIndividualMatchMessage(currentUserData.discord_uid, targetData.discord_uid) - ); - } - } - - // --- Team match cross-check --- - // This only runs if the user swiped RIGHT but did NOT get an individual match. - // Scenario: the current user is a team leader who swiped RIGHT on a solo user. - // We check if that solo user already swiped RIGHT on the leader's team (via swipeTeam). - // If yes → mutual interest → trigger a team match (target joins the team). - - if (direction === SwipeDirection.RIGHT && !matched) { - - // Check if the current user is a leader with open slots. - const {inTeam, isLeader, teamFull} = await getUserTeamStatus(uid); - if (inTeam && isLeader && !teamFull) { - - // Get the leader's team doc. - const leaderTeamSnap = await db - .collection(TEAMS) - .where("leader", "==", uid) - .limit(1) - .get(); - - if (!leaderTeamSnap.empty) { - const leaderTeamDoc = leaderTeamSnap.docs[0]; - const leaderTeamData = leaderTeamDoc.data() as TeamDoc; - - // Check if the target already swiped RIGHT on this team. - // team_swipes doc ID follows the pattern `{swiperId}_{teamId}`. - const teamSwipeSnap = await db - .collection(TEAM_SWIPES) - .doc(`${targetId}_${leaderTeamDoc.id}`) - .get(); - - // Both sides swiped RIGHT → mutual team match → add target to the team. - if (teamSwipeSnap.exists && (teamSwipeSnap.data() as any).direction === SwipeDirection.RIGHT) { - const targetUserData = targetSnapshot.data() as MatchUserDoc; - await handleTeamMatch(targetId, leaderTeamDoc.id, leaderTeamData, targetUserData); - } - } - } - } - - - return res.status(200).json({ - matched, - match: matched ? {id: matchId, discordChannelUrl} : 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; - } - - // All match notifications (individual and team) use the same doc ID pattern: - // `{uid}_match_{matchId}` - const [otherUserSnapshot, notificationSnapshot] = await Promise.all([ - db.collection(USERS).doc(otherUserId).get(), - db.collection(NOTIFICATIONS).doc(`${uid}_match_${doc.id}`).get(), - ]); - - if (!otherUserSnapshot.exists) { - return null; - } - - const otherUserData = otherUserSnapshot.data() as MatchUserDoc; - const notificationData = notificationSnapshot.exists - ? (notificationSnapshot.data() as MatchNotification) - : null; - - return { - id: doc.id, - type: matchData.type ?? "individual", - teamId: matchData.teamId ?? null, - createdAt: matchData.createdAt, - user: buildMatchCardFromUser(otherUserId, otherUserData), - notification: notificationData - ? { - title: notificationData.title, - body: notificationData.body, - seen: notificationData.seen, - createdAt: notificationData.createdAt, - } - : null, - }; - }) - ); - - - 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, 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; - - let discordUsername = ""; - if (otherUserData.discord_uid != null) { - const discordRes = await fetch(`https://elaina.garudahacks.com/api/user/${otherUserData.discord_uid}`); - const discordData = await discordRes.json(); - discordUsername = discordData["username"] ?? ""; - } - - console.log({ - id: matchSnapshot.id, - type: matchData.type ?? "individual", - teamId: matchData.teamId ?? null, - createdAt: matchData.createdAt, - discordChannelUrl: matchData.discordChannelUrl ?? null, - user: buildMatchDeckCard(otherUserId, otherUserData, otherHackCardData, discordUsername), - }) - - return res.status(200).json({ - data: { - id: matchSnapshot.id, - type: matchData.type ?? "individual", - teamId: matchData.teamId ?? null, - createdAt: matchData.createdAt, - discordChannelUrl: matchData.discordChannelUrl ?? null, - user: buildMatchDeckCard(otherUserId, otherUserData, otherHackCardData, discordUsername), - }, - }); - } catch (error) { - functions.logger.error( - `Error when trying getMatchById: ${(error as Error).message}` - ); - return res.status(500).json({ error: (error as Error).message }); - } -}; - - -export const getPassed = 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"}); - } - - // Fetch all left swipes by this user - const passedSwipesSnap = await db - .collection(SWIPES) - .where("swiperId", "==", uid) - .where("direction", "==", SwipeDirection.LEFT) - .get(); - - if (passedSwipesSnap.empty) { - return res.status(200).json({data: []}); - } - - const passedUserIds = passedSwipesSnap.docs.map((doc) => (doc.data() as Swipe).targetId); - - // Batch-fetch user docs and hack cards in parallel - const userRefs = passedUserIds.map((id) => db.collection(USERS).doc(id)); - const [userSnaps, hackCards] = await Promise.all([ - db.getAll(...userRefs), - getHackCardsByUserId(passedUserIds), - ]); - - // Collect discord_uids for batch fetch - const allDiscordUids = new Set(); - userSnaps.forEach((snap) => { - if (snap.exists) { - const d = (snap.data() as MatchUserDoc).discord_uid; - if (d != null) allDiscordUids.add(d); - } - }); - - const discordUsernameMap = new Map(); - await Promise.all( - [...allDiscordUids].map(async (discordUid) => { - const discordRes = await fetch(`https://elaina.garudahacks.com/api/user/${discordUid}`); - const data = await discordRes.json(); - discordUsernameMap.set(discordUid, data["username"] ?? ""); - }) - ); - - const passedCards = userSnaps - .filter((snap) => snap.exists) - .map((snap) => { - const passedUserData = snap.data() as MatchUserDoc; - const discordUsername = passedUserData.discord_uid - ? (discordUsernameMap.get(passedUserData.discord_uid) ?? "") - : ""; - return buildMatchDeckCard(snap.id, passedUserData, hackCards.get(snap.id) ?? null, discordUsername); - }); - - return res.status(200).json({data: passedCards}); - } catch (error) { - functions.logger.error(`Error when trying getPassed: ${(error as Error).message}`); - return res.status(500).json({error: (error as Error).message}); - } -}; - -export const getPassedTeams = 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"}); - } - - // Only individuals (not in any team) can view passed teams - const {inTeam} = await getUserTeamStatus(uid); - if (inTeam) { - return res.status(403).json({error: "You are already in a team"}); - } - - // Fetch left-swiped team IDs from TEAM_SWIPES - const passedTeamSwipesSnap = await db - .collection(TEAM_SWIPES) - .where("swiperId", "==", uid) - .where("direction", "==", SwipeDirection.LEFT) - .get(); - - if (passedTeamSwipesSnap.empty) { - return res.status(200).json({data: []}); - } - - const passedTeamIds = passedTeamSwipesSnap.docs.map((doc) => (doc.data() as any).teamId as string); - - // Phase 1: fetch team cards + team docs in parallel - const teamCardRefs = passedTeamIds.map((id) => db.collection(TEAM_CARDS).doc(id)); - const [teamCardSnaps, teamSnaps] = await Promise.all([ - db.getAll(...teamCardRefs), - Promise.all(passedTeamIds.map((id) => db.collection(TEAMS).doc(id).get())), - ]); - - const validTeams = passedTeamIds - .map((_, i) => ({ - teamCardSnap: teamCardSnaps[i], - teamSnap: teamSnaps[i], - teamData: teamSnaps[i].exists ? (teamSnaps[i].data() as TeamDoc) : null, - memberIds: teamSnaps[i].exists && Array.isArray((teamSnaps[i].data() as TeamDoc).members) - ? (teamSnaps[i].data() as TeamDoc).members! - : [], - })) - .filter(({teamCardSnap, teamSnap}) => teamCardSnap.exists && teamSnap.exists); - - // Phase 2: fetch all member user docs + hack cards in parallel - const allMemberIds = [...new Set(validTeams.flatMap((t) => t.memberIds))]; - - const [allHackCards, teamMemberSnaps] = await Promise.all([ - getHackCardsByUserId(allMemberIds), - Promise.all( - validTeams.map(({memberIds}) => - Promise.all(memberIds.map((id) => db.collection(USERS).doc(id).get())) - ) - ), - ]); - - // Phase 3: collect unique discord_uids, fetch all at once - const allDiscordUids = new Set(); - teamMemberSnaps.forEach((snaps) => { - snaps.forEach((snap) => { - const discordUid = snap.exists ? (snap.data() as MatchUserDoc).discord_uid : undefined; - if (discordUid != null) allDiscordUids.add(discordUid); - }); - }); - - const discordUsernameMap = new Map(); - await Promise.all( - [...allDiscordUids].map(async (discordUid) => { - const discordRes = await fetch(`https://elaina.garudahacks.com/api/user/${discordUid}`); - const data = await discordRes.json(); - discordUsernameMap.set(discordUid, data["username"] ?? ""); - }) - ); - - // Phase 4: assemble results - const teamCards = validTeams.map(({teamCardSnap, teamSnap, teamData, memberIds}, i) => { - const teamCard = teamCardSnap.data() as TeamCardDoc; - const leaderId = teamData?.leader ?? ""; - - const members = memberIds.map((memberId, j) => { - const userSnap = teamMemberSnaps[i][j]; - const memberUserData = userSnap.exists ? (userSnap.data() as MatchUserDoc) : null; - const discordUsername = memberUserData?.discord_uid - ? (discordUsernameMap.get(memberUserData.discord_uid) ?? "") - : ""; - const {firstName, lastName} = resolveUserName(memberUserData ?? {}); - return { - id: memberId, - firstName, - lastName, - isLeader: memberId === leaderId, - discord_uid: memberUserData?.discord_uid ?? "", - discord_username: discordUsername, - ...mapHackCardProfile(allHackCards.get(memberId) ?? null), - }; - }); - - return { - teamId: teamSnap.id, - teamName: teamData?.name ?? "", - memberCount: memberIds.length, - availableSlots: MAX_TEAM_SIZE - memberIds.length, - role: teamCard.role ?? "", - skills: Array.isArray(teamCard.skills) ? teamCard.skills : [], - shortBio: teamCard.short_bio ?? "", - projectInterest: teamCard.project_interest ?? "", - members, - }; - }); - - return res.status(200).json({data: teamCards}); - } catch (error) { - functions.logger.error(`Error when trying getPassedTeams: ${(error as Error).message}`); - return res.status(500).json({error: (error as Error).message}); - } -}; - -export const getTeamDeck = async ( - req: Request, - res: Response -): Promise => { - try { - const uid = getUidFromRequest(req); - if (!uid) { - return res.status(401).json({error: "Unauthorized"}); - } - - const matchOpen = await isMatchOpen(); - if (!matchOpen) { - return res.status(403).json({error: "Matchmaking is currently closed"}); - } - - // Only individuals (not in any team) can browse the team deck - const {inTeam} = await getUserTeamStatus(uid); - if (inTeam) { - return res.status(403).json({error: "You are already in a team"}); - } - - const [teamCardsSnap, swipedSnap] = await Promise.all([ - db.collection(TEAM_CARDS).get(), - db.collection(TEAM_SWIPES).where("swiperId", "==", uid).get(), - ]); - - const swipedTeamIds = new Set(swipedSnap.docs.map((d) => (d.data() as any).teamId as string)); - - const eligibleTeamCards = teamCardsSnap.docs.filter( - (doc) => !swipedTeamIds.has(doc.id) - ); - - // Phase 1: fetch all team docs in parallel - const teamSnaps = await Promise.all( - eligibleTeamCards.map((doc) => db.collection(TEAMS).doc(doc.id).get()) - ); - - // Filter out full teams early, carry metadata forward - const validTeams = eligibleTeamCards - .map((doc, i) => { - const teamData = teamSnaps[i].exists ? (teamSnaps[i].data() as TeamDoc) : null; - const memberIds = Array.isArray(teamData?.members) ? teamData!.members! : []; - return {doc, teamData, memberIds}; - }) - .filter(({memberIds}) => memberIds.length < MAX_TEAM_SIZE); - - // Phase 2: fetch all member user docs + hack cards in parallel across all teams - // Deduplicate member IDs so overlapping members aren't fetched twice - const allMemberIds = [...new Set(validTeams.flatMap((t) => t.memberIds))]; - - const [allHackCards, teamMemberSnaps] = await Promise.all([ - getHackCardsByUserId(allMemberIds), - Promise.all( - validTeams.map(({memberIds}) => - Promise.all(memberIds.map((id) => db.collection(USERS).doc(id).get())) - ) - ), - ]); - - // Phase 3: collect unique discord_uids across all members, fetch all at once - const allDiscordUids = new Set(); - teamMemberSnaps.forEach((snaps) => { - snaps.forEach((snap) => { - const uid = snap.exists ? (snap.data() as MatchUserDoc).discord_uid : undefined; - if (uid != null) allDiscordUids.add(uid); - }); - }); - - const discordUsernameMap = new Map(); - await Promise.all( - [...allDiscordUids].map(async (discordUid) => { - const discordRes = await fetch(`https://elaina.garudahacks.com/api/user/${discordUid}`); - const data = await discordRes.json(); - discordUsernameMap.set(discordUid, data["username"] ?? ""); - }) - ); - - // Phase 4: assemble results synchronously - const teamCards = validTeams.map(({doc, teamData, memberIds}, i) => { - const teamCard = doc.data() as TeamCardDoc; - const leaderId = teamData?.leader ?? ""; - - const members = memberIds.map((memberId, j) => { - const userSnap = teamMemberSnaps[i][j]; - const userData = userSnap.exists ? (userSnap.data() as MatchUserDoc) : null; - const discordUsername = userData?.discord_uid - ? (discordUsernameMap.get(userData.discord_uid) ?? "") - : ""; - const {firstName, lastName} = resolveUserName(userData ?? {}); - return { - id: memberId, - firstName, - lastName, - isLeader: memberId === leaderId, - discord_uid: userData?.discord_uid ?? "", - discord_username: discordUsername, - ...mapHackCardProfile(allHackCards.get(memberId) ?? null), - }; - }); - - return { - teamId: doc.id, - teamName: teamData?.name ?? "", - memberCount: memberIds.length, - availableSlots: MAX_TEAM_SIZE - memberIds.length, - role: teamCard.role ?? "", - skills: Array.isArray(teamCard.skills) ? teamCard.skills : [], - shortBio: teamCard.short_bio ?? "", - projectInterest: teamCard.project_interest ?? "", - members, - }; - }); - - - - return res.status(200).json({data: teamCards}); - } catch (error) { - functions.logger.error( - `Error when trying getTeamDeck: ${(error as Error).message}` - ); - return res.status(500).json({error: (error as Error).message}); - } -}; - -export const swipeTeam = async ( - req: Request, - res: Response -): Promise => { - try { - const uid = getUidFromRequest(req); - if (!uid) { - return res.status(401).json({error: "Unauthorized"}); - } - - const {teamId, direction} = req.body as { - teamId?: string; - direction?: string; - }; - - if (!teamId || !direction) { - return res.status(400).json({error: "teamId and direction are required"}); - } - - if (!isValidSwipeDirection(direction)) { - return res.status(400).json({error: "direction must be 'left' or 'right'"}); - } - - const matchOpen = await isMatchOpen(); - if (!matchOpen) { - return res.status(403).json({error: "Matchmaking is currently closed"}); - } - - // Only individuals can swipe on teams - const {inTeam} = await getUserTeamStatus(uid); - if (inTeam) { - return res.status(403).json({error: "You are already in a team"}); - } - - // Check already swiped this team - const existingSwipeSnap = await db - .collection(TEAM_SWIPES) - .doc(`${uid}_${teamId}`) - .get(); - if (existingSwipeSnap.exists) { - const existingDirection = (existingSwipeSnap.data() as any).direction; - if (existingDirection === SwipeDirection.RIGHT) { - return res.status(400).json({error: "You have already swiped this team"}); - } - // Left → Right: delete old left swipe and proceed - await db.collection(TEAM_SWIPES).doc(`${uid}_${teamId}`).delete(); - } - - // Validate team exists and is not full - const teamSnap = await db.collection(TEAMS).doc(teamId).get(); - if (!teamSnap.exists) { - return res.status(404).json({error: "Team not found"}); - } - - const teamData = teamSnap.data() as TeamDoc; - const memberCount = Array.isArray(teamData.members) ? teamData.members.length : 0; - - if (Array.isArray(teamData.members) && teamData.members.includes(uid)) { - return res.status(400).json({error: "You are already a member of this team"}); - } - - if (memberCount >= MAX_TEAM_SIZE) { - return res.status(400).json({error: "This team is already full"}); - } - - const currentTime = nowUnixSeconds(); - - // Save swipe - await db.collection(TEAM_SWIPES).doc(`${uid}_${teamId}`).set({ - swiperId: uid, - teamId, - direction, - createdAt: currentTime, - }); - - let teamJoined = false; - - if (direction === SwipeDirection.RIGHT) { - const leaderId = teamData.leader ?? ""; - - // Cross-check: did the leader already swipe RIGHT on this individual? - const leaderSwipeSnap = await db - .collection(SWIPES) - .doc(`${leaderId}_${uid}`) - .get(); - - if (leaderSwipeSnap.exists && (leaderSwipeSnap.data() as any).direction === SwipeDirection.RIGHT) { - // Mutual match → trigger team match and notify both parties - const individualUserSnap = await db.collection(USERS).doc(uid).get(); - const individualUserData = individualUserSnap.exists - ? (individualUserSnap.data() as MatchUserDoc) - : {}; - await handleTeamMatch(uid, teamId, teamData, individualUserData as MatchUserDoc); - teamJoined = true; - } - } - - return res.status(200).json({ - swiped: true, - direction, - teamId, - teamJoined, - }); - } catch (error) { - functions.logger.error( - `Error when trying swipeTeam: ${(error as Error).message}` - ); - return res.status(500).json({error: (error as Error).message}); - } -}; - -export const undoSwipe = async (req: Request, res: Response): Promise => { - try { - const uid = getUidFromRequest(req); - if (!uid) return res.status(401).json({error: "Unauthorized"}); - - const lastSwipeSnap = await db.collection(SWIPES) - .where("swiperId", "==", uid) - .orderBy("createdAt", "desc") - .limit(1) - .get(); - - if (lastSwipeSnap.empty) return res.status(200).json({undone: false}); - - const swipeDoc = lastSwipeSnap.docs[0]; - const swipeData = swipeDoc.data() as Swipe; - - await swipeDoc.ref.delete(); - - // tambahan kecil: kalau ini menyebabkan match individual, hapus match-nya juga - if (swipeData.direction === SwipeDirection.RIGHT) { - const matchId = getSortedMatchId(uid, swipeData.targetId); - const matchRef = db.collection(MATCHES).doc(matchId); - const matchSnap = await matchRef.get(); - if (matchSnap.exists && (matchSnap.data() as Match).type === "individual") { - await matchRef.delete(); // notification biarin aja gak masalah besar - } - } - - return res.status(200).json({undone: true}); - } catch (error) { - return res.status(500).json({error: (error as Error).message}); - } -}; - - diff --git a/functions/src/models/match.ts b/functions/src/models/match.ts deleted file mode 100644 index eae1cf7..0000000 --- a/functions/src/models/match.ts +++ /dev/null @@ -1,143 +0,0 @@ -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 type MatchType = "individual" | "team"; - -export interface Match { - id?: string; - users: [string, string]; - type: MatchType; - teamId?: string; // only for team matches - createdAt: number; // unix timestamp in seconds - discordChannelUrl?: string; -} - -export interface MatchCardDTO { - id: string; - firstName: string; - lastName: string; -} - -// Stored shape of a `users/{uid}` document. -export type MatchUserDoc = { - id?: string; - userId?: string; - firstName?: string; - lastName?: string; - first_name?: string; - last_name?: string; - name?: string; - displayName?: string; - preferredName?: string; - email?: string; - date_of_birth?: string; - education?: string; - school?: string; - gender_identity?: string; - github?: string; - linkedin?: string; - portfolio?: string; - year?: string; - status?: string; - mentor?: boolean; - admin?: boolean; - discord_uid?: string; - match_enabled?: boolean; - team?: string; - acceptedAt?: unknown; - createdAt?: unknown; - updatedAt?: unknown; -}; - -// Stored shape of a `hack_cards/{uid}` document (client writes snake_case). -export type HackCardDoc = { - uid?: string; - username?: string; - role?: string; - skills?: string[]; - short_bio?: string; - project_interest?: string; - avatar_url?: string | null; - created_at?: unknown; -}; - -// Stored shape of a `teams/{teamId}` document. -export type TeamDoc = { - id?: string; - name?: string; - leader?: string; - members?: string[]; - createdAt?: unknown; -}; - -export type TeamCardDoc = { - teamId?: string; - role?: string; - skills?: string[]; - short_bio?: string; - project_interest?: string; - created_at?: unknown; -}; - - -// Profile fields sourced from the `hack_cards` collection. -export interface HackCardProfile { - username: string; - role: string; - skills: string[]; - shortBio: string; - projectInterest: string; - avatarUrl: string; -} - -// Deck/swipe card: identity (from `users`) + hack-card profile. -export interface MatchDeckCardDTO extends MatchCardDTO, HackCardProfile { - discord_uid: string; - discord_username: string; -} - -// 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 => ({ - id: data.id || "", - firstName: data.firstName || "", - lastName: data.lastName || "", -}); - -export const formatMatchDeckCard = ( - data: Partial & { id?: string } -): MatchDeckCardDTO => ({ - id: data.id || "", - firstName: data.firstName || "", - lastName: data.lastName || "", - username: data.username || "", - role: data.role || "", - skills: data.skills || [], - shortBio: data.shortBio || "", - projectInterest: data.projectInterest || "", - avatarUrl: data.avatarUrl || "", - discord_uid: data.discord_uid || "", - discord_username: data.discord_username || "", -}); - -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 deleted file mode 100644 index e91586b..0000000 --- a/functions/src/models/notification.ts +++ /dev/null @@ -1,46 +0,0 @@ -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; - discordChannelUrl?: string | null; -} - -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, -}); diff --git a/functions/src/routes/index.ts b/functions/src/routes/index.ts index 1bd2967..3f5ee4a 100644 --- a/functions/src/routes/index.ts +++ b/functions/src/routes/index.ts @@ -4,7 +4,6 @@ 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(); @@ -13,6 +12,5 @@ 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 deleted file mode 100644 index f6df70d..0000000 --- a/functions/src/routes/match.ts +++ /dev/null @@ -1,66 +0,0 @@ -import express, { Request, Response } from "express"; -import { - getDeck, - getMatchById, - getMatchConfigHandler, - getMatches, - getMatchStatus, getTeamDeck, - optInToMatch, - getPassed, - getPassedTeams, - swipe, swipeTeam, undoSwipe, -} 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); -}); - - -router.get("/team-deck", async (req: Request, res: Response) => { - await getTeamDeck(req, res); -}); - -router.post("/team-swipe", async (req: Request, res: Response) => { - await swipeTeam(req, res); -}); - - -router.delete("/swipe", async (req: Request, res: Response) => { - await undoSwipe(req, res); -}); - -router.get("/passed", async (req: Request, res: Response) => { - await getPassed(req, res); -}); - -router.get("/passed-teams", async (req: Request, res: Response) => { - await getPassedTeams(req, res); -}); - -export default router; diff --git a/functions/src/types/config.ts b/functions/src/types/config.ts index ee39487..39614ff 100644 --- a/functions/src/types/config.ts +++ b/functions/src/types/config.ts @@ -1,13 +1,7 @@ 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; -} - -export interface MatchConfig { - isMatchOpen: boolean; - startDate: Timestamp; - endDate: Timestamp; -} + isMentorshipOpen: boolean; // whether or not participant can start to book mentorship slots + mentorshipStartDate: Timestamp; + mentorshipEndDate: Timestamp; +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index cb8a8ee..0000000 --- a/package-lock.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "web-be", - "lockfileVersion": 3, - "requires": true, - "packages": {} -}