Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
9441d96
Add watch balance field to IUser amd create IWatchAdsBalance type
DarinHajou Aug 9, 2025
3bbe043
Update @types/swagger-ui-express dependency
DarinHajou Aug 9, 2025
11186b9
Add WatchAdsBalance schema with user reference
DarinHajou Aug 9, 2025
4f006f0
Removed availableSecs/lifetimeEarnedSecs to avoid dual source of truth
DarinHajou Aug 9, 2025
952788d
Update watch ads interface
DarinHajou Aug 9, 2025
1ace339
Rename WatchAds to WatchAdsBalance
DarinHajou Aug 9, 2025
0d5ce8b
Tweak WatchAdsBalance model for validation and index safety
DarinHajou Aug 9, 2025
1582796
Add WatchAdsSession model with TTL and one-active-session index
DarinHajou Aug 9, 2025
76acf6f
Import watch ads WatchAdsSessionStatus enum type
DarinHajou Aug 9, 2025
cf2500f
Add WatchAdsSessionStatus enum
DarinHajou Aug 9, 2025
03dec53
Add IAdCredit and IWatchAdsDaily interfaces to types
DarinHajou Aug 9, 2025
ec3b363
Add AdCredit ledger for idempotent segment credits
DarinHajou Aug 11, 2025
db93337
Add WatchAdsDaily counter for tracking daily ad earnings
DarinHajou Aug 11, 2025
0626b58
Add controller for starting and fetching active watch-ads sessions
DarinHajou Aug 11, 2025
883c66e
Handle race condition when starting WatchAdsSession
DarinHajou Aug 11, 2025
dff00fc
Refactor startWatchAdsSession to use typed authUser with IUser
DarinHajou Aug 11, 2025
e5ead09
Add _id field to IUser interface for ObjectId typing
DarinHajou Aug 11, 2025
6646ae9
Remove path comment in watchAds
DarinHajou Aug 11, 2025
285fb77
Add WatchAdsSession service with findActiveSession and createSession …
DarinHajou Aug 11, 2025
a87231a
Delete unused WatchAdsCredit and WatchAdsDaily models
DarinHajou Aug 13, 2025
d0cd51c
Refactor WatchAdsSession logic to handle expiration and race conditions
DarinHajou Aug 13, 2025
99ab7ab
Add or set up watch ads routes
DarinHajou Aug 13, 2025
11ca100
Mount watch ads routes to app entry
DarinHajou Aug 13, 2025
af49d75
Add verifyToken middleware to ad watch route
DarinHajou Aug 13, 2025
778d3ad
Remove local index definition in WatchAdsSessionSchema
DarinHajou Aug 13, 2025
7320bec
Remove local index definition in WatchAdsBalanceSchema
DarinHajou Aug 13, 2025
f8e7cf3
Add validation to reject invalid totalSegments and segmentSecs values
DarinHajou Aug 13, 2025
ed58189
Add validation to reject invalid totalSegments and segmentSecs values
DarinHajou Aug 13, 2025
90b914e
Cast buyer._id to string in buildPaymentData to prevent runtime errors
DarinHajou Aug 13, 2025
8adc0b7
Reduce WatchAdsSession expiration buffer from 24h to 10min
DarinHajou Aug 13, 2025
19d0103
Use nullish coalescing in createSession to enforce valid segmentSecs …
DarinHajou Aug 16, 2025
7460c6e
Remove redundant AD CREDIT and WATCH ADS DAILY model interfaces
DarinHajou Aug 21, 2025
266a6f3
Remove redundant AD CREDIT and WATCH ADS DAILY interfaces
DarinHajou Aug 21, 2025
cf1e7f3
Merge branch 'feat/watch-ads-backend' of https://github.com/map-of-pi…
DarinHajou Aug 21, 2025
64e232c
Merge remote-tracking branch 'origin/dev' into feat/watch-ads-backend
swoocn Sep 14, 2025
48357e7
Misc PR adjustments; recover _id in IUser to resolve build.
swoocn Sep 15, 2025
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
3 changes: 2 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@
"@types/supertest": "^6.0.2",
"@types/swagger-jsdoc": "^6.0.4",
"@types/swagger-ui-dist": "^3.30.5",
"@types/swagger-ui-express": "^4.1.6",
"@types/swagger-ui-express": "^4.1.8",
"@vercel/node": "^2.10.3",
"jest": "^29.7.0",
"mongodb-memory-server": "^10.0.0",
Expand Down
44 changes: 44 additions & 0 deletions src/controllers/watchAdsSessionController.ts
Copy link
Member

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. 🙃

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) => {
Copy link
Member

Choose a reason for hiding this comment

The 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);
Copy link
Member

@swoocn swoocn Sep 14, 2025

Choose a reason for hiding this comment

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

Why not use the IUser pi_uid similar to other operations? 🤔
As a reminder, pi_uid is distinct across all Pi Network users and is the reliable identifier to build on in accordance with the PiCT.

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,
Copy link
Member

Choose a reason for hiding this comment

The 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" });
}
};
24 changes: 24 additions & 0 deletions src/models/WatchAdsBalance.ts
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
);
42 changes: 42 additions & 0 deletions src/models/WatchAdsSession.ts
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
);
9 changes: 9 additions & 0 deletions src/models/enums/watchAds.ts
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];
10 changes: 10 additions & 0 deletions src/routes/watchAds.routes.ts
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;
87 changes: 87 additions & 0 deletions src/services/watchAdsSession.service.ts
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();
}

27 changes: 27 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Copy link
Member

Choose a reason for hiding this comment

The 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. 🙃

Copy link
Member

Choose a reason for hiding this comment

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

Okay, I see how the _id field is being used in the watchAdsSessionController. But, is there a reason why you chose not to use the existing pi_uid instead, since it's already a unique identifier for the Pioneer?

pi_uid: string;
pi_username: string;
user_name: string;
Expand Down Expand Up @@ -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
// ========================
Expand Down
2 changes: 2 additions & 0 deletions src/utils/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import paymentsRouter from "../routes/payment.routes";
import reviewFeedbackRoutes from "../routes/reviewFeedback.routes";
import mapCenterRoutes from "../routes/mapCenter.routes";
import notificationRoutes from "../routes/notification.routes";
import watchAdsRoutes from '../routes/watchAds.routes';
import restrictionRoutes from "../routes/restriction.routes";
import toggleRoutes from "../routes/toggle.routes";
import cronRoutes from "../routes/cron.routes";
Expand Down Expand Up @@ -53,6 +54,7 @@ app.use('/api/v1/payments', paymentsRouter);
app.use("/api/v1/review-feedback", reviewFeedbackRoutes);
app.use("/api/v1/map-center", mapCenterRoutes);
app.use("/api/v1/notifications", notificationRoutes);
app.use('/api/v1', watchAdsRoutes);
app.use("/api/v1/restrictions", restrictionRoutes);
app.use("/api/v1/toggles", toggleRoutes);

Expand Down