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:
Sase Dev
2026-02-12 04:25:08 +00:00
parent 8fb7bbbaca
commit f5d1ed9bc3
8 changed files with 300 additions and 36 deletions

View File

@@ -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();

View File

@@ -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,

View File

@@ -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")

View File

@@ -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()}`,
},
};

View File

@@ -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);

View File

@@ -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");

View File

@@ -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")

View File

@@ -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 };
}
}