Repository navigation
feat: implement Free Fire compatible teammate finder MVP #36
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,105 @@ | ||
| -- CreateEnum | ||
| CREATE TYPE "FreeFireTeammateRequestStatus" AS ENUM ('PENDING', 'ACCEPTED', 'DECLINED', 'CANCELLED', 'EXPIRED'); | ||
|
|
||
| -- CreateTable | ||
| CREATE TABLE "FreeFireProfile" ( | ||
| "id" TEXT NOT NULL, | ||
| "userId" TEXT NOT NULL, | ||
| "freeFireUid" TEXT, | ||
| "freeFireUsername" TEXT, | ||
| "preferredMode" TEXT NOT NULL DEFAULT 'BR_SQUAD', | ||
| "rank" TEXT NOT NULL DEFAULT 'HEROIC', | ||
| "playstyle" TEXT NOT NULL DEFAULT 'BALANCED', | ||
| "language" TEXT NOT NULL DEFAULT 'ENGLISH', | ||
| "micPreference" BOOLEAN NOT NULL DEFAULT true, | ||
| "availability" TEXT NOT NULL DEFAULT 'EVENING', | ||
| "teammatePreference" TEXT, | ||
| "isVerified" BOOLEAN NOT NULL DEFAULT false, | ||
| "reputationScore" DOUBLE PRECISION NOT NULL DEFAULT 5.0, | ||
| "completedSessions" INTEGER NOT NULL DEFAULT 0, | ||
| "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, | ||
| "updatedAt" TIMESTAMP(3) NOT NULL, | ||
|
|
||
| CONSTRAINT "FreeFireProfile_pkey" PRIMARY KEY ("id") | ||
| ); | ||
|
|
||
| -- CreateTable | ||
| CREATE TABLE "FreeFireTeammateRequest" ( | ||
| "id" TEXT NOT NULL, | ||
| "senderId" TEXT NOT NULL, | ||
| "receiverId" TEXT NOT NULL, | ||
| "status" "FreeFireTeammateRequestStatus" NOT NULL DEFAULT 'PENDING', | ||
| "message" TEXT, | ||
| "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, | ||
| "updatedAt" TIMESTAMP(3) NOT NULL, | ||
|
|
||
| CONSTRAINT "FreeFireTeammateRequest_pkey" PRIMARY KEY ("id") | ||
| ); | ||
|
|
||
| -- CreateTable | ||
| CREATE TABLE "FreeFireSessionRating" ( | ||
| "id" TEXT NOT NULL, | ||
| "raterId" TEXT NOT NULL, | ||
| "ratedUserId" TEXT NOT NULL, | ||
| "rating" INTEGER NOT NULL, | ||
| "feedback" TEXT, | ||
| "communicationRating" INTEGER, | ||
| "reliabilityRating" INTEGER, | ||
| "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, | ||
|
|
||
| CONSTRAINT "FreeFireSessionRating_pkey" PRIMARY KEY ("id") | ||
| ); | ||
|
|
||
| -- CreateIndex | ||
| CREATE UNIQUE INDEX "FreeFireProfile_userId_key" ON "FreeFireProfile"("userId"); | ||
|
|
||
| -- CreateIndex | ||
| CREATE INDEX "FreeFireProfile_userId_idx" ON "FreeFireProfile"("userId"); | ||
|
|
||
| -- CreateIndex | ||
| CREATE INDEX "FreeFireProfile_rank_idx" ON "FreeFireProfile"("rank"); | ||
|
|
||
| -- CreateIndex | ||
| CREATE INDEX "FreeFireProfile_playstyle_idx" ON "FreeFireProfile"("playstyle"); | ||
|
|
||
| -- CreateIndex | ||
| CREATE INDEX "FreeFireProfile_availability_idx" ON "FreeFireProfile"("availability"); | ||
|
|
||
| -- CreateIndex | ||
| CREATE INDEX "FreeFireProfile_reputationScore_idx" ON "FreeFireProfile"("reputationScore"); | ||
|
|
||
| -- CreateIndex | ||
| CREATE INDEX "FreeFireTeammateRequest_senderId_idx" ON "FreeFireTeammateRequest"("senderId"); | ||
|
|
||
| -- CreateIndex | ||
| CREATE INDEX "FreeFireTeammateRequest_receiverId_idx" ON "FreeFireTeammateRequest"("receiverId"); | ||
|
|
||
| -- CreateIndex | ||
| CREATE INDEX "FreeFireTeammateRequest_status_idx" ON "FreeFireTeammateRequest"("status"); | ||
|
|
||
| -- CreateIndex | ||
| CREATE UNIQUE INDEX "FreeFireTeammateRequest_senderId_receiverId_status_key" ON "FreeFireTeammateRequest"("senderId", "receiverId", "status"); | ||
|
|
||
| -- CreateIndex | ||
| CREATE INDEX "FreeFireSessionRating_raterId_idx" ON "FreeFireSessionRating"("raterId"); | ||
|
|
||
| -- CreateIndex | ||
| CREATE INDEX "FreeFireSessionRating_ratedUserId_idx" ON "FreeFireSessionRating"("ratedUserId"); | ||
|
|
||
| -- CreateIndex | ||
| CREATE UNIQUE INDEX "FreeFireSessionRating_raterId_ratedUserId_key" ON "FreeFireSessionRating"("raterId", "ratedUserId"); | ||
|
|
||
| -- AddForeignKey | ||
| ALTER TABLE "FreeFireProfile" ADD CONSTRAINT "FreeFireProfile_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; | ||
|
|
||
| -- AddForeignKey | ||
| ALTER TABLE "FreeFireTeammateRequest" ADD CONSTRAINT "FreeFireTeammateRequest_senderId_fkey" FOREIGN KEY ("senderId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; | ||
|
|
||
| -- AddForeignKey | ||
| ALTER TABLE "FreeFireTeammateRequest" ADD CONSTRAINT "FreeFireTeammateRequest_receiverId_fkey" FOREIGN KEY ("receiverId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; | ||
|
|
||
| -- AddForeignKey | ||
| ALTER TABLE "FreeFireSessionRating" ADD CONSTRAINT "FreeFireSessionRating_raterId_fkey" FOREIGN KEY ("raterId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; | ||
|
|
||
| -- AddForeignKey | ||
| ALTER TABLE "FreeFireSessionRating" ADD CONSTRAINT "FreeFireSessionRating_ratedUserId_fkey" FOREIGN KEY ("ratedUserId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,69 @@ | ||
| import { Response } from 'express'; | ||
| import { AuthRequest } from '../types'; | ||
| import { freeFireService } from '../services/freefire.service'; | ||
| import { asyncHandler } from '../utils/asyncHandler'; | ||
| import { sendSuccess } from '../utils/response'; | ||
|
|
||
| export class FreeFireController { | ||
| getProfile = asyncHandler(async (req: AuthRequest, res: Response) => { | ||
| const userId = req.user!.userId; | ||
| const profile = await freeFireService.getProfile(userId); | ||
| sendSuccess(res, profile); | ||
| }); | ||
|
|
||
| updateProfile = asyncHandler(async (req: AuthRequest, res: Response) => { | ||
| const userId = req.user!.userId; | ||
| const profile = await freeFireService.updateProfile(userId, req.body); | ||
| sendSuccess(res, profile); | ||
| }); | ||
|
|
||
| discoverTeammates = asyncHandler(async (req: AuthRequest, res: Response) => { | ||
| const userId = req.user!.userId; | ||
| const { rank, playstyle, language, availability, limit } = req.query; | ||
|
|
||
| const candidates = await freeFireService.discoverTeammates(userId, { | ||
| rank: rank as string, | ||
| playstyle: playstyle as string, | ||
| language: language as string, | ||
| availability: availability as string, | ||
| limit: limit ? parseInt(limit as string) : 20, | ||
| }); | ||
|
|
||
| sendSuccess(res, candidates); | ||
| }); | ||
|
|
||
| sendRequest = asyncHandler(async (req: AuthRequest, res: Response) => { | ||
| const senderId = req.user!.userId; | ||
| const { receiverId, message } = req.body; | ||
|
|
||
| const request = await freeFireService.sendTeammateRequest(senderId, receiverId, message); | ||
| sendSuccess(res, request, 'Teammate request sent successfully'); | ||
| }); | ||
|
|
||
| updateRequestStatus = asyncHandler(async (req: AuthRequest, res: Response) => { | ||
| const userId = req.user!.userId; | ||
| const { id } = req.params; | ||
| const { status } = req.body; | ||
|
|
||
| const updated = await freeFireService.updateTeammateRequestStatus(id, userId, status); | ||
| sendSuccess(res, updated, `Request status updated to ${status}`); | ||
| }); | ||
|
|
||
| rateTeammate = asyncHandler(async (req: AuthRequest, res: Response) => { | ||
| const raterId = req.user!.userId; | ||
| const { ratedUserId, rating, feedback, communicationRating, reliabilityRating } = req.body; | ||
|
|
||
| const result = await freeFireService.rateTeammate( | ||
| raterId, | ||
| ratedUserId, | ||
| parseInt(rating), | ||
| feedback, | ||
| communicationRating ? parseInt(communicationRating) : undefined, | ||
| reliabilityRating ? parseInt(reliabilityRating) : undefined | ||
| ); | ||
|
|
||
| sendSuccess(res, result, 'Teammate rating submitted successfully'); | ||
| }); | ||
| } | ||
|
|
||
| export const freeFireController = new FreeFireController(); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| import { Router } from 'express'; | ||
| import { freeFireController } from '../controllers/freefire.controller'; | ||
| import { authenticate } from '../middleware/auth'; | ||
|
|
||
| const router = Router(); | ||
|
|
||
| router.get('/profile', authenticate, freeFireController.getProfile.bind(freeFireController)); | ||
| router.post('/profile', authenticate, freeFireController.updateProfile.bind(freeFireController)); | ||
| router.get('/teammates', authenticate, freeFireController.discoverTeammates.bind(freeFireController)); | ||
| router.post('/request', authenticate, freeFireController.sendRequest.bind(freeFireController)); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The request API has no read/list endpoint, while the new page only posts invites and never queries or displays received requests. The POST response (including its ID) only reaches the sender, so a receiver has no way to discover an invite or obtain the ID required by the PATCH endpoint; invites therefore remain pending in the shipped UI. Add a receiver inbox/list or notification flow with accept/decline actions. Useful? React with 👍 / 👎. |
||
| router.patch('/request/:id', authenticate, freeFireController.updateRequestStatus.bind(freeFireController)); | ||
| router.post('/rate', authenticate, freeFireController.rateTeammate.bind(freeFireController)); | ||
|
|
||
| export default router; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,52 @@ | ||
| import { freeFireService } from './freefire.service'; | ||
|
|
||
| describe('FreeFireService - Compatibility Scoring Logic', () => { | ||
| it('should calculate high compatibility when language, playstyle, mic, and availability match', () => { | ||
| const userA = { | ||
| language: 'ENGLISH', | ||
| playstyle: 'AGGRESSIVE', | ||
| micPreference: true, | ||
| availability: 'EVENING', | ||
| }; | ||
|
|
||
| const userB = { | ||
| language: 'ENGLISH', | ||
| playstyle: 'AGGRESSIVE', | ||
| micPreference: true, | ||
| availability: 'EVENING', | ||
| reputationScore: 4.8, | ||
| }; | ||
|
|
||
| const match = freeFireService.calculateCompatibility(userA, userB); | ||
|
|
||
| expect(match.score).toBe(100); // 50 + 20 (lang) + 15 (playstyle) + 10 (mic) + 15 (avail) + 10 (rep) capped at 100 | ||
| expect(match.reasons).toContain('Speaks ENGLISH'); | ||
| expect(match.reasons).toContain('Matching AGGRESSIVE playstyle'); | ||
| expect(match.reasons).toContain('Uses Microphone'); | ||
| expect(match.reasons).toContain('Available EVENING'); | ||
| }); | ||
|
|
||
| it('should handle partial compatibility gracefully', () => { | ||
| const userA = { | ||
| language: 'ENGLISH', | ||
| playstyle: 'DEFENSIVE', | ||
| micPreference: false, | ||
| availability: 'WEEKENDS', | ||
| }; | ||
|
|
||
| const userB = { | ||
| language: 'ENGLISH', | ||
| playstyle: 'AGGRESSIVE', | ||
| micPreference: false, | ||
| availability: 'EVENING', | ||
| reputationScore: 3.5, | ||
| }; | ||
|
|
||
| const match = freeFireService.calculateCompatibility(userA, userB); | ||
|
|
||
| expect(match.score).toBe(80); // 50 + 20 (lang) + 10 (mic) = 80 | ||
| expect(match.reasons).toContain('Speaks ENGLISH'); | ||
| expect(match.reasons).toContain('No-Mic Preferred'); | ||
| expect(match.reasons).not.toContain('Matching AGGRESSIVE playstyle'); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The service permits a new request once the prior one is no longer
PENDING, but this constraint permits only one row for each terminal status as well. For example, after a request is declined, a new request can be created, but declining that second request conflicts with the existing(senderId, receiverId, DECLINED)row and produces an unhandled database error. Make the uniqueness rule apply only to pending requests or remove status from this composite key.Useful? React with 👍 / 👎.