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") @Get("stats/daily")
async getDailyStats() { async getDailyStats() {
return this.adminService.getDailyStats(); return this.adminService.getDailyStats();

View File

@@ -1,5 +1,5 @@
import { Inject, Injectable, Logger, NotFoundException } from "@nestjs/common"; 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 { DATABASE, Database } from "../database/database.provider";
import { import {
users, users,
@@ -7,6 +7,7 @@ import {
payments, payments,
queryLogs, queryLogs,
brands, brands,
referrals,
} from "../database/schema/core"; } from "../database/schema/core";
@Injectable() @Injectable()
@@ -111,7 +112,7 @@ export class AdminService {
.from(userSubscriptions) .from(userSubscriptions)
.where( .where(
and( and(
sql`${userSubscriptions.userId} = ANY(${userIds})`, inArray(userSubscriptions.userId, userIds),
eq(userSubscriptions.status, "active"), 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() { async getDailyStats() {
const thirtyDaysAgo = new Date( const thirtyDaysAgo = new Date(
Date.now() - 30 * 24 * 60 * 60 * 1000, Date.now() - 30 * 24 * 60 * 60 * 1000,

View File

@@ -23,9 +23,9 @@ export class PaymentsController {
@Post("iyzico/initialize") @Post("iyzico/initialize")
async initializeIyzico( async initializeIyzico(
@CurrentUser("id") userId: string, @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") @Post("iyzico/callback")
@@ -42,9 +42,9 @@ export class PaymentsController {
@Post("eft") @Post("eft")
async createEft( async createEft(
@CurrentUser("id") userId: string, @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") @Post("eft/:id/receipt")

View File

@@ -8,10 +8,17 @@ import {
import { ConfigService } from "@nestjs/config"; import { ConfigService } from "@nestjs/config";
import { eq, and, desc } from "drizzle-orm"; import { eq, and, desc } from "drizzle-orm";
import { DATABASE, Database } from "../database/database.provider"; 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 { SubscriptionsService } from "../subscriptions/subscriptions.service";
import { StorageService } from "../storage/storage.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() @Injectable()
export class PaymentsService { export class PaymentsService {
private readonly logger = new Logger(PaymentsService.name); private readonly logger = new Logger(PaymentsService.name);
@@ -23,24 +30,48 @@ export class PaymentsService {
private storageService: StorageService, private storageService: StorageService,
) {} ) {}
async initializeIyzico(userId: string, subscriptionId: string) { private async resolvePlanId(planKey: string): Promise<string> {
// Validate subscription belongs to user const brandCount = PLAN_KEY_TO_BRAND_COUNT[planKey];
const [sub] = await this.db if (brandCount === undefined) throw new BadRequestException(`Invalid plan key: ${planKey}`);
const [plan] = await this.db
.select() .select()
.from(userSubscriptions) .from(plans)
.where(and(eq(userSubscriptions.id, subscriptionId), eq(userSubscriptions.userId, userId))) .where(and(eq(plans.brandCount, brandCount), eq(plans.isActive, true)))
.limit(1); .limit(1);
if (!sub) throw new NotFoundException("Subscription not found"); if (!plan) throw new NotFoundException("Plan not found");
if (sub.status === "active") throw new BadRequestException("Subscription already active"); 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 const [payment] = await this.db
.insert(payments) .insert(payments)
.values({ .values({
userId, userId,
subscriptionId, subscriptionId: sub.id,
amount, amount,
currency: "TRY", currency: "TRY",
method: "iyzico", method: "iyzico",
@@ -49,13 +80,11 @@ export class PaymentsService {
.returning(); .returning();
// TODO: Integrate with actual iyzico API // TODO: Integrate with actual iyzico API
// For now, return a mock payment initialization this.logger.log(`iyzico payment initialized for subscription ${sub.id}`);
this.logger.log(`iyzico payment initialized for subscription ${subscriptionId}`);
return { return {
paymentId: payment.id, paymentId: payment.id,
status: "pending", status: "pending",
// In production: checkoutFormContent, paymentPageUrl, etc.
}; };
} }
@@ -86,21 +115,18 @@ export class PaymentsService {
return { status: newStatus }; return { status: newStatus };
} }
async createEftPayment(userId: string, subscriptionId: string) { async createEftPayment(userId: string, planKey: string, billingPeriod: "monthly" | "yearly", brandIds: string[]) {
const [sub] = await this.db const sub = await this.createSubscriptionForPayment(userId, planKey, billingPeriod, brandIds);
.select()
.from(userSubscriptions)
.where(and(eq(userSubscriptions.id, subscriptionId), eq(userSubscriptions.userId, userId)))
.limit(1);
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 const [payment] = await this.db
.insert(payments) .insert(payments)
.values({ .values({
userId, userId,
subscriptionId, subscriptionId: sub.id,
amount: 0, // Will be set from plan pricing amount,
currency: "TRY", currency: "TRY",
method: "eft", method: "eft",
status: "pending", status: "pending",
@@ -110,9 +136,9 @@ export class PaymentsService {
return { return {
paymentId: payment.id, paymentId: payment.id,
bankInfo: { bankInfo: {
bankName: "İş Bankası", bankName: "Ziraat Bankası",
iban: "TR00 0000 0000 0000 0000 0000 00", iban: "TR33 0001 0000 1234 5678 9012 34",
accountHolder: "Sase Teknoloji Ltd.", accountHolder: "Sase Teknoloji A.Ş.",
description: `SASE-${payment.id.substring(0, 8).toUpperCase()}`, 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 { export class ReferralsController {
constructor(private referralsService: ReferralsService) {} constructor(private referralsService: ReferralsService) {}
@Get("stats")
async getStats(@CurrentUser("id") userId: string) {
return this.referralsService.getStats(userId);
}
@Get("me") @Get("me")
async getMyReferrals(@CurrentUser("id") userId: string) { async getMyReferrals(@CurrentUser("id") userId: string) {
return this.referralsService.getMyReferrals(userId); return this.referralsService.getMyReferrals(userId);

View File

@@ -13,6 +13,27 @@ export class ReferralsService {
private subscriptionsService: SubscriptionsService, 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) { async getMyReferrals(userId: string) {
const user = await this.db.select().from(users).where(eq(users.id, userId)).limit(1); 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"); 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 { UsersService } from "./users.service";
import { CurrentUser } from "../common/decorators/current-user.decorator"; import { CurrentUser } from "../common/decorators/current-user.decorator";
import { Roles } from "../common/decorators/roles.decorator"; import { Roles } from "../common/decorators/roles.decorator";
@@ -21,6 +21,32 @@ export class UsersController {
return this.usersService.updateProfile(userId, body); 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") @Get(":id")
@UseGuards(RolesGuard) @UseGuards(RolesGuard)
@Roles("admin") @Roles("admin")

View File

@@ -1,7 +1,8 @@
import { Inject, Injectable, NotFoundException } from "@nestjs/common"; import { BadRequestException, Inject, Injectable, NotFoundException } from "@nestjs/common";
import { eq } from "drizzle-orm"; import { eq, and } from "drizzle-orm";
import { hashPassword, verifyPassword } from "better-auth/crypto";
import { DATABASE, Database } from "../database/database.provider"; import { DATABASE, Database } from "../database/database.provider";
import { users } from "../database/schema/core"; import { users, accounts } from "../database/schema/core";
@Injectable() @Injectable()
export class UsersService { export class UsersService {
@@ -33,6 +34,81 @@ export class UsersService {
return result[0]; 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) { async findAll(page: number = 1, limit: number = 20) {
const offset = (page - 1) * limit; const offset = (page - 1) * limit;
const [items, countResult] = await Promise.all([ const [items, countResult] = await Promise.all([
@@ -41,4 +117,5 @@ export class UsersService {
]); ]);
return { items, total: countResult.length }; return { items, total: countResult.length };
} }
} }