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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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;
75 changes: 75 additions & 0 deletions server/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,11 @@ model User {
blockedBy UserBlock[] @relation("BlockedBy")
videoClips VideoClip[]
montageProjects MontageProject[]
freeFireProfile FreeFireProfile?
freeFireRequestsSent FreeFireTeammateRequest[] @relation("FreeFireRequestsSent")
freeFireRequestsReceived FreeFireTeammateRequest[] @relation("FreeFireRequestsReceived")
freeFireRatingsGiven FreeFireSessionRating[] @relation("FreeFireRatingsGiven")
freeFireRatingsReceived FreeFireSessionRating[] @relation("FreeFireRatingsReceived")
profileViews ProfileView[]
postViews PostView[]
chatMessageReactions ChatMessageReaction[]
Expand Down Expand Up @@ -1484,3 +1489,73 @@ model MontageProject {
@@index([status])
@@index([createdAt])
}

// ─── Free Fire MVP: Teammate Discovery & Trust ──────────────────────────────

enum FreeFireTeammateRequestStatus {
PENDING
ACCEPTED
DECLINED
CANCELLED
EXPIRED
}

model FreeFireProfile {
id String @id @default(uuid())
userId String @unique
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
freeFireUid String? // Self-reported UID
freeFireUsername String? // Self-reported IGN
preferredMode String @default("BR_SQUAD") // BR_SQUAD, CS_SQUAD, LONE_WOLF, DUO
rank String @default("HEROIC") // BRONZE, SILVER, GOLD, PLATINUM, DIAMOND, HEROIC, GRANDMASTER
playstyle String @default("BALANCED") // AGGRESSIVE, BALANCED, DEFENSIVE, SUPPORT, STRATEGIC
language String @default("ENGLISH")
micPreference Boolean @default(true)
availability String @default("EVENING") // NOW, TODAY, EVENING, WEEKENDS
teammatePreference String? // Description of desired partner
isVerified Boolean @default(false) // Always false for self-reported Free Fire stats per policy
reputationScore Float @default(5.0)
completedSessions Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

@@index([userId])
@@index([rank])
@@index([playstyle])
@@index([availability])
@@index([reputationScore])
}

model FreeFireTeammateRequest {
id String @id @default(uuid())
senderId String
sender User @relation("FreeFireRequestsSent", fields: [senderId], references: [id], onDelete: Cascade)
receiverId String
receiver User @relation("FreeFireRequestsReceived", fields: [receiverId], references: [id], onDelete: Cascade)
status FreeFireTeammateRequestStatus @default(PENDING)
message String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

@@unique([senderId, receiverId, status])

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Allow repeated terminal requests from the same pair

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 👍 / 👎.

@@index([senderId])
@@index([receiverId])
@@index([status])
}

model FreeFireSessionRating {
id String @id @default(uuid())
raterId String
rater User @relation("FreeFireRatingsGiven", fields: [raterId], references: [id], onDelete: Cascade)
ratedUserId String
ratedUser User @relation("FreeFireRatingsReceived", fields: [ratedUserId], references: [id], onDelete: Cascade)
rating Int // 1 to 5 stars
feedback String?
communicationRating Int? // 1 to 5 stars
reliabilityRating Int? // 1 to 5 stars
createdAt DateTime @default(now())

@@unique([raterId, ratedUserId])
@@index([raterId])
@@index([ratedUserId])
}
69 changes: 69 additions & 0 deletions server/src/controllers/freefire.controller.ts
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();
2 changes: 2 additions & 0 deletions server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import cryptoRoutes from './routes/crypto.routes';
import steamRoutes from './routes/steam.routes';
import gameStatsRoutes from './routes/game-stats.routes';
import clashOfClansRoutes from './routes/clashofclans.routes';
import freeFireRoutes from './routes/freefire.routes';
import challengeRoutes from './routes/challenge.routes';
import videoRoutes from './routes/video.routes';
import { setSocketIo } from './socket-emitter';
Expand Down Expand Up @@ -218,6 +219,7 @@ app.use('/api/compare', compareRoutes);
app.use('/api/game-sync', gameSyncRoutes);
app.use('/api/game-stats', gameStatsRoutes);
app.use('/api/game', gameModularRoutes);
app.use('/api/freefire', freeFireRoutes);
app.use('/api/challenges', challengeRoutes);
app.use('/api/video', videoRoutes);

Expand Down
14 changes: 14 additions & 0 deletions server/src/routes/freefire.routes.ts
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));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Add a receiver-facing request flow

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;
2 changes: 1 addition & 1 deletion server/src/services/feed.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ export class FeedService {
this.ensureOfficialWelcomePostExists().catch(() => {});
}

let whereClause: any = { isPublished: true };
const whereClause: any = { isPublished: true };
if (userId) {
const following = await prisma.follow.findMany({
where: { followerId: userId },
Expand Down
52 changes: 52 additions & 0 deletions server/src/services/freefire.service.test.ts
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');
});
});
Loading
Loading