feat: expand API endpoints — user account management, referral stats, payment flow refactor, admin referrals
- Users: add connections, unlink, change-password, delete-account endpoints - Referrals: add GET /stats endpoint with tier reward calculation - Payments: refactor initializeIyzico/createEft to accept planKey+billingPeriod+brandIds instead of subscriptionId, auto-create subscription during payment flow - Admin: add getReferrals endpoint with search/pagination, fix inArray import Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -48,6 +48,19 @@ export class AdminController {
|
||||
);
|
||||
}
|
||||
|
||||
@Get("referrals")
|
||||
async getReferrals(
|
||||
@Query("search") search?: string,
|
||||
@Query("page") page?: string,
|
||||
@Query("limit") limit?: string,
|
||||
) {
|
||||
return this.adminService.getReferrals(
|
||||
search,
|
||||
page ? parseInt(page, 10) : 1,
|
||||
limit ? parseInt(limit, 10) : 20,
|
||||
);
|
||||
}
|
||||
|
||||
@Get("stats/daily")
|
||||
async getDailyStats() {
|
||||
return this.adminService.getDailyStats();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Inject, Injectable, Logger, NotFoundException } from "@nestjs/common";
|
||||
import { and, count, desc, eq, gte, ilike, or, sql } from "drizzle-orm";
|
||||
import { and, count, desc, eq, gte, ilike, inArray, or, sql } from "drizzle-orm";
|
||||
import { DATABASE, Database } from "../database/database.provider";
|
||||
import {
|
||||
users,
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
payments,
|
||||
queryLogs,
|
||||
brands,
|
||||
referrals,
|
||||
} from "../database/schema/core";
|
||||
|
||||
@Injectable()
|
||||
@@ -111,7 +112,7 @@ export class AdminService {
|
||||
.from(userSubscriptions)
|
||||
.where(
|
||||
and(
|
||||
sql`${userSubscriptions.userId} = ANY(${userIds})`,
|
||||
inArray(userSubscriptions.userId, userIds),
|
||||
eq(userSubscriptions.status, "active"),
|
||||
),
|
||||
)
|
||||
@@ -229,6 +230,101 @@ export class AdminService {
|
||||
};
|
||||
}
|
||||
|
||||
async getReferrals(search?: string, page = 1, limit = 20) {
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
// Get referrers: users who have at least one referral
|
||||
// We aggregate referral counts per referrer
|
||||
const referrerAlias = users;
|
||||
|
||||
const conditions = search
|
||||
? or(
|
||||
ilike(users.name, `%${search}%`),
|
||||
ilike(users.email, `%${search}%`),
|
||||
)
|
||||
: undefined;
|
||||
|
||||
// Get all referrals with referrer and referred user info
|
||||
const allReferrals = await this.db
|
||||
.select({
|
||||
id: referrals.id,
|
||||
referrerId: referrals.referrerId,
|
||||
referredId: referrals.referredId,
|
||||
rewardApplied: referrals.rewardApplied,
|
||||
createdAt: referrals.createdAt,
|
||||
})
|
||||
.from(referrals)
|
||||
.orderBy(desc(referrals.createdAt));
|
||||
|
||||
// Get unique referrer IDs
|
||||
const referrerIds = [...new Set(allReferrals.map((r) => r.referrerId))];
|
||||
const referredIds = [...new Set(allReferrals.map((r) => r.referredId))];
|
||||
const allUserIds = [...new Set([...referrerIds, ...referredIds])];
|
||||
|
||||
// Get all relevant users
|
||||
const allUsers = allUserIds.length > 0
|
||||
? await this.db
|
||||
.select({
|
||||
id: users.id,
|
||||
name: users.name,
|
||||
email: users.email,
|
||||
referralCode: users.referralCode,
|
||||
})
|
||||
.from(users)
|
||||
.where(inArray(users.id, allUserIds))
|
||||
: [];
|
||||
|
||||
const userMap = new Map(allUsers.map((u) => [u.id, u]));
|
||||
|
||||
// Build referrer summary with counts
|
||||
const referrerSummary = referrerIds.map((referrerId) => {
|
||||
const referrer = userMap.get(referrerId);
|
||||
const refs = allReferrals.filter((r) => r.referrerId === referrerId);
|
||||
return {
|
||||
referrerId,
|
||||
referrerName: referrer?.name || "?",
|
||||
referrerEmail: referrer?.email || "?",
|
||||
referralCode: referrer?.referralCode || null,
|
||||
totalReferrals: refs.length,
|
||||
referrals: refs.map((r) => {
|
||||
const referred = userMap.get(r.referredId);
|
||||
return {
|
||||
id: r.id,
|
||||
referredId: r.referredId,
|
||||
referredName: referred?.name || "?",
|
||||
referredEmail: referred?.email || "?",
|
||||
rewardApplied: r.rewardApplied,
|
||||
createdAt: r.createdAt,
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
// Apply search filter
|
||||
const filtered = search
|
||||
? referrerSummary.filter(
|
||||
(r) =>
|
||||
r.referrerName.toLowerCase().includes(search.toLowerCase()) ||
|
||||
r.referrerEmail.toLowerCase().includes(search.toLowerCase()),
|
||||
)
|
||||
: referrerSummary;
|
||||
|
||||
// Sort by total referrals descending
|
||||
filtered.sort((a, b) => b.totalReferrals - a.totalReferrals);
|
||||
|
||||
const total = filtered.length;
|
||||
const paginated = filtered.slice(offset, offset + limit);
|
||||
|
||||
return {
|
||||
items: paginated,
|
||||
total,
|
||||
totalReferrals: allReferrals.length,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
};
|
||||
}
|
||||
|
||||
async getDailyStats() {
|
||||
const thirtyDaysAgo = new Date(
|
||||
Date.now() - 30 * 24 * 60 * 60 * 1000,
|
||||
|
||||
@@ -23,9 +23,9 @@ export class PaymentsController {
|
||||
@Post("iyzico/initialize")
|
||||
async initializeIyzico(
|
||||
@CurrentUser("id") userId: string,
|
||||
@Body() body: { subscriptionId: string },
|
||||
@Body() body: { planKey: string; billingPeriod: "monthly" | "yearly"; brandIds: string[] },
|
||||
) {
|
||||
return this.paymentsService.initializeIyzico(userId, body.subscriptionId);
|
||||
return this.paymentsService.initializeIyzico(userId, body.planKey, body.billingPeriod, body.brandIds);
|
||||
}
|
||||
|
||||
@Post("iyzico/callback")
|
||||
@@ -42,9 +42,9 @@ export class PaymentsController {
|
||||
@Post("eft")
|
||||
async createEft(
|
||||
@CurrentUser("id") userId: string,
|
||||
@Body() body: { subscriptionId: string },
|
||||
@Body() body: { planKey: string; billingPeriod: "monthly" | "yearly"; brandIds: string[] },
|
||||
) {
|
||||
return this.paymentsService.createEftPayment(userId, body.subscriptionId);
|
||||
return this.paymentsService.createEftPayment(userId, body.planKey, body.billingPeriod, body.brandIds);
|
||||
}
|
||||
|
||||
@Post("eft/:id/receipt")
|
||||
|
||||
@@ -8,10 +8,17 @@ import {
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { eq, and, desc } from "drizzle-orm";
|
||||
import { DATABASE, Database } from "../database/database.provider";
|
||||
import { payments, userSubscriptions } from "../database/schema/core";
|
||||
import { payments, userSubscriptions, plans } from "../database/schema/core";
|
||||
import { SubscriptionsService } from "../subscriptions/subscriptions.service";
|
||||
import { StorageService } from "../storage/storage.service";
|
||||
|
||||
const PLAN_KEY_TO_BRAND_COUNT: Record<string, number> = {
|
||||
brand1: 1,
|
||||
brand2: 2,
|
||||
brand3: 3,
|
||||
full: 0,
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class PaymentsService {
|
||||
private readonly logger = new Logger(PaymentsService.name);
|
||||
@@ -23,24 +30,48 @@ export class PaymentsService {
|
||||
private storageService: StorageService,
|
||||
) {}
|
||||
|
||||
async initializeIyzico(userId: string, subscriptionId: string) {
|
||||
// Validate subscription belongs to user
|
||||
const [sub] = await this.db
|
||||
private async resolvePlanId(planKey: string): Promise<string> {
|
||||
const brandCount = PLAN_KEY_TO_BRAND_COUNT[planKey];
|
||||
if (brandCount === undefined) throw new BadRequestException(`Invalid plan key: ${planKey}`);
|
||||
|
||||
const [plan] = await this.db
|
||||
.select()
|
||||
.from(userSubscriptions)
|
||||
.where(and(eq(userSubscriptions.id, subscriptionId), eq(userSubscriptions.userId, userId)))
|
||||
.from(plans)
|
||||
.where(and(eq(plans.brandCount, brandCount), eq(plans.isActive, true)))
|
||||
.limit(1);
|
||||
|
||||
if (!sub) throw new NotFoundException("Subscription not found");
|
||||
if (sub.status === "active") throw new BadRequestException("Subscription already active");
|
||||
if (!plan) throw new NotFoundException("Plan not found");
|
||||
return plan.id;
|
||||
}
|
||||
|
||||
private async createSubscriptionForPayment(
|
||||
userId: string,
|
||||
planKey: string,
|
||||
billingPeriod: "monthly" | "yearly",
|
||||
brandIds: string[],
|
||||
) {
|
||||
const planId = await this.resolvePlanId(planKey);
|
||||
const subscription = await this.subscriptionsService.create(userId, { planId, brandIds, billingPeriod });
|
||||
|
||||
if (brandIds.length > 0 && planKey !== "full") {
|
||||
await this.subscriptionsService.addBrandsToSubscription(subscription.id, userId, brandIds);
|
||||
}
|
||||
|
||||
return subscription;
|
||||
}
|
||||
|
||||
async initializeIyzico(userId: string, planKey: string, billingPeriod: "monthly" | "yearly", brandIds: string[]) {
|
||||
const sub = await this.createSubscriptionForPayment(userId, planKey, billingPeriod, brandIds);
|
||||
|
||||
const amount = billingPeriod === "yearly"
|
||||
? (await this.db.select().from(plans).where(eq(plans.id, sub.planId)).limit(1))[0].priceYearly
|
||||
: (await this.db.select().from(plans).where(eq(plans.id, sub.planId)).limit(1))[0].priceMonthly;
|
||||
|
||||
// Create payment record
|
||||
const amount = sub.billingPeriod === "yearly" ? 0 : 0; // Will be calculated from plan
|
||||
const [payment] = await this.db
|
||||
.insert(payments)
|
||||
.values({
|
||||
userId,
|
||||
subscriptionId,
|
||||
subscriptionId: sub.id,
|
||||
amount,
|
||||
currency: "TRY",
|
||||
method: "iyzico",
|
||||
@@ -49,13 +80,11 @@ export class PaymentsService {
|
||||
.returning();
|
||||
|
||||
// TODO: Integrate with actual iyzico API
|
||||
// For now, return a mock payment initialization
|
||||
this.logger.log(`iyzico payment initialized for subscription ${subscriptionId}`);
|
||||
this.logger.log(`iyzico payment initialized for subscription ${sub.id}`);
|
||||
|
||||
return {
|
||||
paymentId: payment.id,
|
||||
status: "pending",
|
||||
// In production: checkoutFormContent, paymentPageUrl, etc.
|
||||
};
|
||||
}
|
||||
|
||||
@@ -86,21 +115,18 @@ export class PaymentsService {
|
||||
return { status: newStatus };
|
||||
}
|
||||
|
||||
async createEftPayment(userId: string, subscriptionId: string) {
|
||||
const [sub] = await this.db
|
||||
.select()
|
||||
.from(userSubscriptions)
|
||||
.where(and(eq(userSubscriptions.id, subscriptionId), eq(userSubscriptions.userId, userId)))
|
||||
.limit(1);
|
||||
async createEftPayment(userId: string, planKey: string, billingPeriod: "monthly" | "yearly", brandIds: string[]) {
|
||||
const sub = await this.createSubscriptionForPayment(userId, planKey, billingPeriod, brandIds);
|
||||
|
||||
if (!sub) throw new NotFoundException("Subscription not found");
|
||||
const [plan] = await this.db.select().from(plans).where(eq(plans.id, sub.planId)).limit(1);
|
||||
const amount = billingPeriod === "yearly" ? plan.priceYearly : plan.priceMonthly;
|
||||
|
||||
const [payment] = await this.db
|
||||
.insert(payments)
|
||||
.values({
|
||||
userId,
|
||||
subscriptionId,
|
||||
amount: 0, // Will be set from plan pricing
|
||||
subscriptionId: sub.id,
|
||||
amount,
|
||||
currency: "TRY",
|
||||
method: "eft",
|
||||
status: "pending",
|
||||
@@ -110,9 +136,9 @@ export class PaymentsService {
|
||||
return {
|
||||
paymentId: payment.id,
|
||||
bankInfo: {
|
||||
bankName: "İş Bankası",
|
||||
iban: "TR00 0000 0000 0000 0000 0000 00",
|
||||
accountHolder: "Sase Teknoloji Ltd.",
|
||||
bankName: "Ziraat Bankası",
|
||||
iban: "TR33 0001 0000 1234 5678 9012 34",
|
||||
accountHolder: "Sase Teknoloji A.Ş.",
|
||||
description: `SASE-${payment.id.substring(0, 8).toUpperCase()}`,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -6,6 +6,11 @@ import { CurrentUser } from "../common/decorators/current-user.decorator";
|
||||
export class ReferralsController {
|
||||
constructor(private referralsService: ReferralsService) {}
|
||||
|
||||
@Get("stats")
|
||||
async getStats(@CurrentUser("id") userId: string) {
|
||||
return this.referralsService.getStats(userId);
|
||||
}
|
||||
|
||||
@Get("me")
|
||||
async getMyReferrals(@CurrentUser("id") userId: string) {
|
||||
return this.referralsService.getMyReferrals(userId);
|
||||
|
||||
@@ -13,6 +13,27 @@ export class ReferralsService {
|
||||
private subscriptionsService: SubscriptionsService,
|
||||
) {}
|
||||
|
||||
async getStats(userId: string) {
|
||||
const user = await this.db.select().from(users).where(eq(users.id, userId)).limit(1);
|
||||
if (user.length === 0) throw new NotFoundException("User not found");
|
||||
|
||||
const totalResult = await this.db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(referrals)
|
||||
.where(eq(referrals.referrerId, userId));
|
||||
|
||||
const totalReferrals = Number(totalResult[0]?.count || 0);
|
||||
|
||||
let rewardDays = 0;
|
||||
if (totalReferrals >= REFERRAL_REWARDS.TIER_2.count) {
|
||||
rewardDays = REFERRAL_REWARDS.TIER_2.extensionDays;
|
||||
} else if (totalReferrals >= REFERRAL_REWARDS.TIER_1.count) {
|
||||
rewardDays = REFERRAL_REWARDS.TIER_1.extensionDays;
|
||||
}
|
||||
|
||||
return { totalReferrals, rewardDays };
|
||||
}
|
||||
|
||||
async getMyReferrals(userId: string) {
|
||||
const user = await this.db.select().from(users).where(eq(users.id, userId)).limit(1);
|
||||
if (user.length === 0) throw new NotFoundException("User not found");
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Controller, Get, Patch, Body, Param, Query, UseGuards } from "@nestjs/common";
|
||||
import { Controller, Get, Patch, Post, Delete, Body, Param, Query, UseGuards } from "@nestjs/common";
|
||||
import { UsersService } from "./users.service";
|
||||
import { CurrentUser } from "../common/decorators/current-user.decorator";
|
||||
import { Roles } from "../common/decorators/roles.decorator";
|
||||
@@ -21,6 +21,32 @@ export class UsersController {
|
||||
return this.usersService.updateProfile(userId, body);
|
||||
}
|
||||
|
||||
@Get("me/connections")
|
||||
async getConnections(@CurrentUser("id") userId: string) {
|
||||
return this.usersService.getConnections(userId);
|
||||
}
|
||||
|
||||
@Delete("me/connections/:provider")
|
||||
async unlinkConnection(
|
||||
@CurrentUser("id") userId: string,
|
||||
@Param("provider") provider: string,
|
||||
) {
|
||||
return this.usersService.unlinkConnection(userId, provider);
|
||||
}
|
||||
|
||||
@Post("me/change-password")
|
||||
async changePassword(
|
||||
@CurrentUser("id") userId: string,
|
||||
@Body() body: { currentPassword: string; newPassword: string },
|
||||
) {
|
||||
return this.usersService.changePassword(userId, body.currentPassword, body.newPassword);
|
||||
}
|
||||
|
||||
@Delete("me")
|
||||
async deleteAccount(@CurrentUser("id") userId: string) {
|
||||
return this.usersService.deleteAccount(userId);
|
||||
}
|
||||
|
||||
@Get(":id")
|
||||
@UseGuards(RolesGuard)
|
||||
@Roles("admin")
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Inject, Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { BadRequestException, Inject, Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { hashPassword, verifyPassword } from "better-auth/crypto";
|
||||
import { DATABASE, Database } from "../database/database.provider";
|
||||
import { users } from "../database/schema/core";
|
||||
import { users, accounts } from "../database/schema/core";
|
||||
|
||||
@Injectable()
|
||||
export class UsersService {
|
||||
@@ -33,6 +34,81 @@ export class UsersService {
|
||||
return result[0];
|
||||
}
|
||||
|
||||
async getConnections(userId: string) {
|
||||
const userAccounts = await this.db
|
||||
.select({ providerId: accounts.providerId })
|
||||
.from(accounts)
|
||||
.where(eq(accounts.userId, userId));
|
||||
|
||||
const providers = userAccounts.map((a) => a.providerId);
|
||||
return {
|
||||
google: providers.includes("google"),
|
||||
};
|
||||
}
|
||||
|
||||
async unlinkConnection(userId: string, provider: string) {
|
||||
const result = await this.db
|
||||
.delete(accounts)
|
||||
.where(
|
||||
and(
|
||||
eq(accounts.userId, userId),
|
||||
eq(accounts.providerId, provider),
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
|
||||
if (result.length === 0) {
|
||||
throw new NotFoundException("Connection not found");
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async changePassword(userId: string, currentPassword: string, newPassword: string) {
|
||||
const [account] = await this.db
|
||||
.select()
|
||||
.from(accounts)
|
||||
.where(
|
||||
and(
|
||||
eq(accounts.userId, userId),
|
||||
eq(accounts.providerId, "credential"),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!account?.password) {
|
||||
throw new BadRequestException("No password set for this account");
|
||||
}
|
||||
|
||||
const isValid = await verifyPassword({ hash: account.password, password: currentPassword });
|
||||
if (!isValid) {
|
||||
throw new BadRequestException("Current password is incorrect");
|
||||
}
|
||||
|
||||
if (newPassword.length < 8) {
|
||||
throw new BadRequestException("Password must be at least 8 characters");
|
||||
}
|
||||
|
||||
const hashedPassword = await hashPassword(newPassword);
|
||||
await this.db
|
||||
.update(accounts)
|
||||
.set({ password: hashedPassword, updatedAt: new Date() })
|
||||
.where(eq(accounts.id, account.id));
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async deleteAccount(userId: string) {
|
||||
const result = await this.db
|
||||
.delete(users)
|
||||
.where(eq(users.id, userId))
|
||||
.returning();
|
||||
|
||||
if (result.length === 0) {
|
||||
throw new NotFoundException("User not found");
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async findAll(page: number = 1, limit: number = 20) {
|
||||
const offset = (page - 1) * limit;
|
||||
const [items, countResult] = await Promise.all([
|
||||
@@ -41,4 +117,5 @@ export class UsersService {
|
||||
]);
|
||||
return { items, total: countResult.length };
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user