-
Notifications
You must be signed in to change notification settings - Fork 3
Feat/watch ads backend #307
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
9441d96
3bbe043
11186b9
4f006f0
952788d
1ace339
0d5ce8b
1582796
76acf6f
cf2500f
03dec53
ec3b363
db93337
0626b58
883c66e
dff00fc
e5ead09
6646ae9
285fb77
a87231a
d0cd51c
99ab7ab
11ca100
af49d75
778d3ad
7320bec
f8e7cf3
ed58189
90b914e
8adc0b7
19d0103
7460c6e
266a6f3
cf1e7f3
64e232c
48357e7
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,44 @@ | ||
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) => { | ||
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. This function seems a bit complex in the controller layer. Perhaps we should consider pushing the complexity down into the service layer so the controller just orchestrates.. |
||
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); | ||
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. Why not use the IUser pi_uid similar to other operations? 🤔 |
||
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, | ||
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. So, your controller function is hard-coding defaults and then the invoked service function is also hard-coding defaults. Reason for this redundancy..? 🤔 |
||
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" }); | ||
} | ||
}; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,24 @@ | ||
import { Schema, model } from 'mongoose'; | ||
import { IWatchAdsBalance } from '../types'; | ||
|
||
const WatchAdsBalanceSchema = new Schema<IWatchAdsBalance>( | ||
{ | ||
userId: { | ||
type: Schema.Types.ObjectId, | ||
ref: 'User', | ||
required: true, | ||
unique: true, | ||
}, | ||
availableSecs: { type: Number, default: 0, min: 0 }, | ||
lifetimeEarnedSecs: { type: Number, default: 0, min: 0 } | ||
}, | ||
{ timestamps: true } | ||
); | ||
|
||
// Redundant with `unique: true` above, but fine to keep for clarity | ||
WatchAdsBalanceSchema.index({ userId: 1 }, { unique: true }); | ||
|
||
export const WatchAdsBalance = model<IWatchAdsBalance>( | ||
'WatchAdsBalance', | ||
WatchAdsBalanceSchema | ||
); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,42 @@ | ||
import { Schema, model } from 'mongoose'; | ||
import type { IWatchAdsSession } from '../types'; | ||
import { WATCH_ADS_SESSION_STATUS } from '../models/enums/watchAds'; | ||
|
||
const WatchAdsSessionSchema = new Schema<IWatchAdsSession>( | ||
{ | ||
userId: { | ||
type: Schema.Types.ObjectId, | ||
ref: 'User', | ||
required: true, | ||
}, | ||
status: { | ||
type: String, | ||
enum: Object.values(WATCH_ADS_SESSION_STATUS), | ||
default: WATCH_ADS_SESSION_STATUS.Running, | ||
index: true, | ||
}, | ||
totalSegments: { type: Number, required: true, min: 1, default: 20 }, | ||
segmentSecs: { type: Number, required: true, min: 1, default: 30 }, | ||
completedSegments: { type: Number, default: 0, min: 0 }, | ||
earnedSecs: { type: Number, default: 0, min: 0 }, | ||
startedAt: { type: Date, default: () => new Date() }, | ||
endedAt: { type: Date }, | ||
expiresAt: { | ||
type: Date, | ||
required: true, | ||
index: { expireAfterSeconds: 0 } // MongoDB TTL cleanup | ||
}, | ||
}, | ||
{ timestamps: true } | ||
); | ||
|
||
// Enforce: only one running session per user | ||
WatchAdsSessionSchema.index( | ||
{ userId: 1 }, | ||
{ unique: true, partialFilterExpression: { status: WATCH_ADS_SESSION_STATUS.Running } } | ||
); | ||
|
||
export const WatchAdsSession = model<IWatchAdsSession>( | ||
'WatchAdsSession', | ||
WatchAdsSessionSchema | ||
); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,9 @@ | ||
export const WATCH_ADS_SESSION_STATUS = { | ||
Running: 'running', | ||
Completed: 'completed', | ||
Expired: 'expired', | ||
Aborted: 'aborted', | ||
} as const; | ||
|
||
export type WatchAdsSessionStatus = | ||
typeof WATCH_ADS_SESSION_STATUS[keyof typeof WATCH_ADS_SESSION_STATUS]; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,10 @@ | ||
import { Router } from 'express'; | ||
import { startWatchAdsSession } from '../controllers/watchAdsSessionController'; | ||
import { verifyToken } from '../middlewares/verifyToken'; | ||
|
||
const router = Router(); | ||
|
||
// start/resume endpoint | ||
router.post('/watch-ads/session', verifyToken, startWatchAdsSession); | ||
|
||
export default router; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,87 @@ | ||
import { Types } from 'mongoose'; | ||
import { WatchAdsSession } from '../models/WatchAdsSession'; | ||
import { WATCH_ADS_SESSION_STATUS } from '../models/enums/watchAds'; | ||
|
||
type CreateOpts = { | ||
status?: string; // should be WATCH_ADS_SESSION_STATUS.Running | ||
totalSegments?: number; // default 20 | ||
segmentSecs?: number; // default 30 | ||
expiresAt?: Date; // default now + 24h | ||
}; | ||
|
||
export async function findActiveSession(userId: Types.ObjectId) { | ||
const now = new Date(); | ||
await WatchAdsSession.updateMany( | ||
{ userId, status: WATCH_ADS_SESSION_STATUS.Running, expiresAt: { $lte: now } }, | ||
{ $set: { status: WATCH_ADS_SESSION_STATUS.Expired, endedAt: now } } | ||
); | ||
return WatchAdsSession.findOne({ | ||
userId, status: WATCH_ADS_SESSION_STATUS.Running, expiresAt: { $gt: now } | ||
}).lean(); | ||
} | ||
|
||
export async function createSession(userId: Types.ObjectId, opts: CreateOpts = {}) { | ||
const nowMs = Date.now(); | ||
const now = new Date(nowMs); | ||
|
||
const { | ||
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 | ||
), | ||
} = opts; | ||
|
||
|
||
// Validation | ||
if (totalSegments <= 0 || segmentSecs <= 0) { | ||
throw new Error("Invalid session parameters: totalSegments and segmentSecs must be greater than 0"); | ||
} | ||
|
||
// 1. Expire any stale running sessions *before* we do anything else | ||
await WatchAdsSession.updateMany( | ||
{ userId, status: WATCH_ADS_SESSION_STATUS.Running, expiresAt: { $lte: now } }, | ||
{ $set: { status: WATCH_ADS_SESSION_STATUS.Expired, endedAt: now } } | ||
); | ||
|
||
let attempt = 0; | ||
while (attempt < 3) { | ||
try { | ||
const doc = await WatchAdsSession.create({ | ||
userId, | ||
status, | ||
totalSegments, | ||
segmentSecs, | ||
completedSegments: 0, | ||
earnedSecs: 0, | ||
startedAt: now, | ||
expiresAt, | ||
}); | ||
return doc.toObject(); | ||
} catch (err: any) { | ||
if (err?.code === 11000) { | ||
// Another session got created in the meantime — return it if valid | ||
const active = await WatchAdsSession.findOne({ | ||
userId, | ||
status: WATCH_ADS_SESSION_STATUS.Running, | ||
expiresAt: { $gt: new Date() }, | ||
}).lean(); | ||
if (active) return active; | ||
|
||
// Otherwise, loop and try creating again | ||
attempt++; | ||
continue; | ||
} | ||
throw err; | ||
} | ||
} | ||
|
||
// If we hit here, repeated collisions — just return the active one if any | ||
return WatchAdsSession.findOne({ | ||
userId, | ||
status: WATCH_ADS_SESSION_STATUS.Running, | ||
expiresAt: { $gt: new Date() }, | ||
}).lean(); | ||
} | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -10,11 +10,13 @@ import {OrderStatusType} from "./models/enums/orderStatusType"; | |
import {OrderItemStatusType} from "./models/enums/orderItemStatusType"; | ||
import {PaymentType} from "./models/enums/paymentType"; | ||
import {U2UPaymentStatus} from "./models/enums/u2uPaymentStatus"; | ||
import { WatchAdsSessionStatus } from "./models/enums/watchAds"; | ||
|
||
// ======================== | ||
// USER MODELS | ||
// ======================== | ||
export interface IUser extends Document { | ||
_id: Types.ObjectId; | ||
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. Since IUser already extends Document, the _id field is automatically included by default. Adding the _id attribute explicitly isn’t wrong, but it doesn’t look like _id is being referenced anywhere in the code; therefore, it feels redundant. Please feel free to prove me wrong. 🙃 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. Okay, I see how the |
||
pi_uid: string; | ||
pi_username: string; | ||
user_name: string; | ||
|
@@ -299,6 +301,31 @@ export interface INotification extends Document { | |
updatedAt: Date; | ||
}; | ||
|
||
// ======================== | ||
// WATCH ADS | ||
// ======================== | ||
export interface IWatchAdsBalance extends Document { | ||
userId: Types.ObjectId; | ||
availableSecs: number; | ||
lifetimeEarnedSecs: number; | ||
createdAt: Date; | ||
updatedAt: Date; | ||
} | ||
|
||
export interface IWatchAdsSession extends Document { | ||
userId: Types.ObjectId; | ||
status: WatchAdsSessionStatus; | ||
totalSegments: number; | ||
segmentSecs: number; | ||
completedSegments: number; | ||
earnedSecs: number; | ||
startedAt: Date; | ||
endedAt?: Date; | ||
expiresAt: Date; | ||
createdAt: Date; | ||
updatedAt: Date; | ||
} | ||
|
||
// ======================== | ||
// SANCTIONS / GEO-RESTRICTIONS | ||
// ======================== | ||
|
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.
What do you think about renaming this controller to adsController, which would allow us to group multiple ad operations under a single domain? I'm thinking more about extensibility at this point. 🙃