Skip to content
Open
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
106 changes: 65 additions & 41 deletions src/controllers/watchAdsSessionController.ts
Original file line number Diff line number Diff line change
@@ -1,44 +1,68 @@
import { Request, Response } from "express";
import { WATCH_ADS_SESSION_STATUS } from "../models/enums/watchAds";
import * as WatchAdsSessionService from "../services/watchAdsSession.service";
import logger from "../config/loggingConfig";
import { IUser } from "../types";


// POST /api/v1/watch-ads/session
export const startWatchAdsSession = async (req: Request, res: Response) => {
const authUser = req.currentUser as IUser;
if (!authUser) {
logger.warn("No authenticated user found when trying to start watch-ads session.");
return res.status(401).json({ error: "Unauthorized" });
import { Request, Response } from "express";
import { WATCH_ADS_SESSION_STATUS } from "../models/enums/watchAds";
import * as WatchAdsSessionService from "../services/watchAdsSession.service";
import logger from "../config/loggingConfig";
import { IUser } from "../types";
import { WatchAdsBalance } from "../models/WatchAdsBalance";

// POST /api/v1/watch-ads/session
export const startWatchAdsSession = async (req: Request, res: Response) => {
const authUser = req.currentUser as IUser;
if (!authUser) {
logger.warn("No authenticated user found when trying to start watch-ads session.");
return res.status(401).json({ error: "Unauthorized" });
}

try {
// Step 1: Check if user already has an active session
const activeSession = await WatchAdsSessionService.findActiveSession(authUser._id);
if (activeSession) {
return res.json(activeSession);
}

try {
// Step 1: Check if user already has an active session
const activeSession = await WatchAdsSessionService.findActiveSession(authUser._id);
if (activeSession) {
return res.json(activeSession);
}

// Step 2: Create a new session
const newSession = await WatchAdsSessionService.createSession(authUser._id, {
status: WATCH_ADS_SESSION_STATUS.Running,
totalSegments: 20,
segmentSecs: 30,
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // SESSION_TTL = 24h
});

return res.status(201).json(newSession);

} catch (err: any) {
// Handle race condition where another request created it just now
if (err.code === 11000) {
logger.warn(`Race detected: active WatchAdsSession already exists for user ${authUser._id}`);
const existingSession = await WatchAdsSessionService.findActiveSession(authUser._id);
if (existingSession) return res.json(existingSession);
}

logger.error("Error starting watch-ads session", err);
return res.status(500).json({ error: "Internal server error" });
// Step 2: Create a new session
const newSession = await WatchAdsSessionService.createSession(authUser._id, {
status: WATCH_ADS_SESSION_STATUS.Running,
totalSegments: 20,
segmentSecs: 30,
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // SESSION_TTL = 24h
});

return res.status(201).json(newSession);

} catch (err: any) {
// Handle race condition where another request created it just now
if (err.code === 11000) {
logger.warn(`Race detected: active WatchAdsSession already exists for user ${authUser._id}`);
const existingSession = await WatchAdsSessionService.findActiveSession(authUser._id);
if (existingSession) return res.json(existingSession);
}
};

logger.error("Error starting watch-ads session", err);
return res.status(500).json({ error: "Internal server error" });
}
};

// POST /api/v1/watch-ads/session/:id/segment-complete
export const completeSegment = async (req: Request, res: Response) => {
const authUser = req.currentUser as IUser;
if (!authUser) {
return res.status(401).json({ error: "Unauthorized" });
}

try {
const { id } = req.params;
const { adId } = req.body;

const updatedSession = await WatchAdsSessionService.completeSegment(authUser._id, id, adId);
if (!updatedSession) {
return res.status(404).json({ error: "Session not found or expired" });
}

return res.json(updatedSession);

} catch (err: any) {
logger.error("Error completing watch-ads segment", err);
return res.status(500).json({ error: "Internal server error" });
}
};
4 changes: 3 additions & 1 deletion src/routes/watchAds.routes.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { Router } from 'express';
import { startWatchAdsSession } from '../controllers/watchAdsSessionController';
import { startWatchAdsSession, completeSegment } from '../controllers/watchAdsSessionController';
import { verifyToken } from '../middlewares/verifyToken';

const router = Router();

// start/resume endpoint
router.post('/watch-ads/session', verifyToken, startWatchAdsSession);

router.post('/watch-ads/session/:id/segment-complete', verifyToken, completeSegment);

export default router;
43 changes: 38 additions & 5 deletions src/services/watchAdsSession.service.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Types } from 'mongoose';
import { WatchAdsSession } from '../models/WatchAdsSession';
import { WATCH_ADS_SESSION_STATUS } from '../models/enums/watchAds';
import { WatchAdsBalance } from '../models/WatchAdsBalance';

type CreateOpts = {
status?: string; // should be WATCH_ADS_SESSION_STATUS.Running
Expand Down Expand Up @@ -28,12 +29,9 @@ export async function createSession(userId: Types.ObjectId, opts: CreateOpts = {
status = WATCH_ADS_SESSION_STATUS.Running,
totalSegments = opts.totalSegments ?? 20,
segmentSecs = opts.segmentSecs ?? 30,
expiresAt = opts.expiresAt ?? new Date(
nowMs + (opts.totalSegments ?? 20) * (opts.segmentSecs ?? 30) * 1000 + 10 * 60 * 1000
),
expiresAt = opts.expiresAt ?? new Date(nowMs + 24 * 60 * 60 * 1000),
} = opts;


// Validation
if (totalSegments <= 0 || segmentSecs <= 0) {
throw new Error("Invalid session parameters: totalSegments and segmentSecs must be greater than 0");
Expand Down Expand Up @@ -77,11 +75,46 @@ export async function createSession(userId: Types.ObjectId, opts: CreateOpts = {
}
}

// If we hit here, repeated collisions — just return the active one if any
// If we hit repeated collisions — just return the active one if any
return WatchAdsSession.findOne({
userId,
status: WATCH_ADS_SESSION_STATUS.Running,
expiresAt: { $gt: new Date() },
}).lean();
}

export async function completeSegment(userId: Types.ObjectId, sessionId: string, adId: string) {
// 1. Find active session
const session = await WatchAdsSession.findOne({
_id: sessionId,
userId,
status: WATCH_ADS_SESSION_STATUS.Running,
});
if (!session) return null;

// 2. Increment session progress
session.completedSegments += 1;
session.earnedSecs += session.segmentSecs;

if (session.completedSegments >= session.totalSegments) {
session.status = WATCH_ADS_SESSION_STATUS.Completed;
session.endedAt = new Date();
}

await session.save();

// 3. Increment or create balance
await WatchAdsBalance.findOneAndUpdate(
{ userId },
{
$inc: {
availableSecs: session.segmentSecs,
lifetimeEarnedSecs: session.segmentSecs,
},
},
{ upsert: true, new: true }
);

// 4. Return updated session
return session.toObject();
}