change(gamification): sezon ödülleri otomatik tanımlanmaz — kazananlar admin'e maillenir
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled

expert-rewards cron'u artık üyelik uzatmıyor: ay kapanışında ilk 3'ü
hesaplayıp admin@sase.tr'ye Postal üzerinden kazanan listesini yollar
(sıra, ad, e-posta, puan, ödül 30/15/7 gün, kullanıcı ID) ve
oem_expert_rewards'a denetim satırı yazar; günleri admin elle tanımlar.
Dev DB'den atılan mailler "[dev]" konu önekiyle ayrışır. Mail-önce-satır-
sonra sırası retry'da mail kaybını önler (nadir çift mail kabul edilir).
Abonelik/kredi dokunuşu tamamen kaldırıldı.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 11:53:02 +03:00
parent 579ee8d275
commit 2c848df97d
4 changed files with 128 additions and 84 deletions

View File

@@ -745,12 +745,12 @@ export const oemSuggestions = pgTable(
// ─── OEM Expert Rewards (monthly leaderboard prizes) ── // ─── OEM Expert Rewards (monthly leaderboard prizes) ──
// One row per (TR-month, rank): the expert-rewards cron (1st of month 00:00 // One row per (TR-month, rank): the expert-rewards cron (1st of month 00:00
// Europe/Istanbul) closes the finished season and grants the top 3 voters a // Europe/Istanbul) closes the finished season, e-mails the top 3 voters to
// subscription extension (30/15/7 days — EXPERT_REWARD_LADDER). The unique // admin@sase.tr (name/e-mail/points + prize 30/15/7 days — EXPERT_REWARD_
// LADDER) and records them here. Subscription days are NOT granted
// automatically — the admin applies them manually from the mail. The unique
// index doubles as the run-once guard: a second run for the same period // index doubles as the run-once guard: a second run for the same period
// inserts nothing, so days are never granted twice. Granting mirrors the // inserts nothing and sends no second mail.
// referral mechanic: extend a live active/trial sub, else bank the days in
// users.referral_credit_days (consumed at next trial/activation).
export const oemExpertRewards = pgTable( export const oemExpertRewards = pgTable(
"oem_expert_rewards", "oem_expert_rewards",
{ {

View File

@@ -130,9 +130,10 @@ export class JobsModule implements OnModuleInit, OnModuleDestroy {
} }
// Parça Uzmanları sezon kapanışı: her ayın 1'i 00:00 Türkiye saati — // Parça Uzmanları sezon kapanışı: her ayın 1'i 00:00 Türkiye saati —
// biten ayın ilk 3 oylayıcısına üyelik uzatması (30/15/7 gün). Ödül // biten ayın ilk 3'ünü admin@sase.tr'ye mailler (üyelik günlerini admin
// yalnız DB'ye yazar (dış yan etki yok), o yüzden dev'de de çalışır; // elle tanımlar) + denetim satırı yazar. Dev'de de çalışır; dev maili
// (period, rank) unique index'i çift vermeyi zaten engeller. // "[dev]" konu önekiyle ayrışır, (period, rank) unique index'i aynı
// dönem için ikinci maili/satırı engeller.
await this.expertRewardsQueue.upsertJobScheduler( await this.expertRewardsQueue.upsertJobScheduler(
"expert-rewards-monthly", "expert-rewards-monthly",
{ pattern: "0 0 1 * *", tz: "Europe/Istanbul" }, { pattern: "0 0 1 * *", tz: "Europe/Istanbul" },

View File

@@ -1,24 +1,34 @@
import { Job } from "bullmq"; import { Job } from "bullmq";
import { and, asc, desc, eq, gte, lt, or, sql } from "drizzle-orm"; import { and, asc, desc, eq, gte, lt, sql } from "drizzle-orm";
import { PostgresJsDatabase } from "drizzle-orm/postgres-js"; import { PostgresJsDatabase } from "drizzle-orm/postgres-js";
import { import { oemExpertRewards, oemVotePoints, users } from "../../database/schema/core";
oemExpertRewards,
oemVotePoints,
userSubscriptions,
users,
} from "../../database/schema/core";
import { EXPERT_REWARD_LADDER, previousTrMonthWindow } from "../../oem-votes/expert-period"; import { EXPERT_REWARD_LADDER, previousTrMonthWindow } from "../../oem-votes/expert-period";
type Database = PostgresJsDatabase<Record<string, unknown>>; type Database = PostgresJsDatabase<Record<string, unknown>>;
const ADMIN_EMAIL = "admin@sase.tr";
interface SeasonWinner {
rank: number;
days: number;
userId: string;
name: string;
email: string;
points: number;
}
// Parça Uzmanları sezon kapanışı: her ayın 1'i 00:00 TR'de biten ayın ilk 3 // Parça Uzmanları sezon kapanışı: her ayın 1'i 00:00 TR'de biten ayın ilk 3
// oylayıcısına üyelik uzatması (30/15/7 gün). oem_expert_rewards'taki // oylayıcısını hesaplar, kazananları admin@sase.tr'ye MAİLLER ve
// (period, rank) unique index run-once garantisidir — yeniden çalıştırma // oem_expert_rewards'a denetim satırı yazar. Üyelik uzatması OTOMATİK
// (retry, elle tetik) hiçbir şeyi ikinci kez vermez. // YAPILMAZ — günleri (30/15/7) admin maildeki listeye göre elle tanımlar.
// (period, rank) unique index run-once garantisidir. Sıra bilinçli: önce
// mail, sonra satırlar — satırlar önce yazılsaydı mail hatasındaki retry
// alreadyGranted'a takılır, mail hiç gitmezdi (nadir çift mail, kayıp
// mailden iyidir).
export async function processExpertRewards( export async function processExpertRewards(
job: Job, job: Job,
db: Database, db: Database,
): Promise<{ period: string; granted: number; skipped: boolean }> { ): Promise<{ period: string; winners: number; skipped: boolean }> {
const { start, end } = previousTrMonthWindow(new Date()); const { start, end } = previousTrMonthWindow(new Date());
const periodLabel = start.toISOString(); const periodLabel = start.toISOString();
console.log(`[expert-rewards] Processing job ${job.id} for period ${periodLabel}`); console.log(`[expert-rewards] Processing job ${job.id} for period ${periodLabel}`);
@@ -30,85 +40,119 @@ export async function processExpertRewards(
.limit(1); .limit(1);
if (alreadyGranted) { if (alreadyGranted) {
console.log(`[expert-rewards] Period ${periodLabel} already granted — skipping`); console.log(`[expert-rewards] Period ${periodLabel} already granted — skipping`);
return { period: periodLabel, granted: 0, skipped: true }; return { period: periodLabel, winners: 0, skipped: true };
} }
// Biten sezonun ilk 3'ü — liderlik tablosuyla aynı sıralama: puan desc, // Biten sezonun ilk 3'ü — liderlik tablosuyla aynı sıralama: puan desc,
// eşitlikte puana daha erken ulaşan önde. // eşitlikte puana daha erken ulaşan önde.
const totalPoints = sql<number>`sum(${oemVotePoints.points})::int`; const totalPoints = sql<number>`sum(${oemVotePoints.points})::int`;
const top = await db const top = await db
.select({ userId: oemVotePoints.userId, points: totalPoints }) .select({
userId: oemVotePoints.userId,
name: users.name,
email: users.email,
points: totalPoints,
})
.from(oemVotePoints) .from(oemVotePoints)
.innerJoin(users, eq(users.id, oemVotePoints.userId))
.where(and(gte(oemVotePoints.createdAt, start), lt(oemVotePoints.createdAt, end))) .where(and(gte(oemVotePoints.createdAt, start), lt(oemVotePoints.createdAt, end)))
.groupBy(oemVotePoints.userId) .groupBy(oemVotePoints.userId, users.name, users.email)
.orderBy(desc(totalPoints), asc(sql`min(${oemVotePoints.createdAt})`)) .orderBy(desc(totalPoints), asc(sql`min(${oemVotePoints.createdAt})`))
.limit(EXPERT_REWARD_LADDER.length); .limit(EXPERT_REWARD_LADDER.length);
if (top.length === 0) { if (top.length === 0) {
console.log(`[expert-rewards] No votes in period ${periodLabel} — nothing to grant`); console.log(`[expert-rewards] No votes in period ${periodLabel} — nothing to report`);
return { period: periodLabel, granted: 0, skipped: false }; return { period: periodLabel, winners: 0, skipped: false };
} }
let granted = 0; const winners: SeasonWinner[] = top.map((row, i) => ({
for (let i = 0; i < top.length; i++) { rank: EXPERT_REWARD_LADDER[i].rank,
const winner = top[i]; days: EXPERT_REWARD_LADDER[i].days,
const { rank, days } = EXPERT_REWARD_LADDER[i]; userId: row.userId,
name: row.name,
email: row.email,
points: row.points,
}));
await db.transaction(async (tx) => { await sendWinnersMail(start, winners);
const inserted = await tx
.insert(oemExpertRewards)
.values({
periodStart: start,
userId: winner.userId,
rank,
points: winner.points,
rewardDays: days,
})
.onConflictDoNothing()
.returning({ id: oemExpertRewards.id });
// Yarış/yeniden-deneme: kayıt zaten varsa gün de verilmiş demektir.
if (inserted.length === 0) return;
// Referral ödül mekaniğinin birebir kopyası (worker Nest DI'sız çalıştığı for (const winner of winners) {
// için ReferralsService.grantRewardDays buradan çağrılamıyor): canlı await db
// active/trial aboneliği uzat, yoksa günleri krediye banka et — kredi bir .insert(oemExpertRewards)
// sonraki trial/aktivasyonda otomatik tüketilir. .values({
const [sub] = await tx periodStart: start,
.select({ id: userSubscriptions.id, endDate: userSubscriptions.endDate }) userId: winner.userId,
.from(userSubscriptions) rank: winner.rank,
.where( points: winner.points,
and( rewardDays: winner.days,
eq(userSubscriptions.userId, winner.userId), })
or(eq(userSubscriptions.status, "active"), eq(userSubscriptions.status, "trial")), .onConflictDoNothing();
),
)
.orderBy(desc(userSubscriptions.endDate))
.limit(1);
if (sub?.endDate) {
const newEnd = new Date(sub.endDate);
newEnd.setDate(newEnd.getDate() + days);
await tx
.update(userSubscriptions)
.set({ endDate: newEnd, updatedAt: new Date() })
.where(eq(userSubscriptions.id, sub.id));
} else {
await tx
.update(users)
.set({
referralCreditDays: sql`${users.referralCreditDays} + ${days}`,
updatedAt: new Date(),
})
.where(eq(users.id, winner.userId));
}
granted++;
console.log(
`[expert-rewards] rank=${rank} user=${winner.userId} points=${winner.points} +${days}d`,
);
});
} }
console.log(`[expert-rewards] Period ${periodLabel}: granted ${granted} reward(s)`); console.log(
return { period: periodLabel, granted, skipped: false }; `[expert-rewards] Period ${periodLabel}: mailed ${winners.length} winner(s) to ${ADMIN_EMAIL}`,
);
return { period: periodLabel, winners: winners.length, skipped: false };
}
const escapeHtml = (value: string) =>
value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
// Worker Nest DI'sız çalıştığı için EmailService kullanılamıyor — aynı Postal
// HTTP çağrısı (email.service.ts ile aynı payload) env üzerinden yapılır.
async function sendWinnersMail(periodStart: Date, winners: SeasonWinner[]): Promise<void> {
const apiUrl = process.env.POSTAL_API_URL;
const apiKey = process.env.POSTAL_API_KEY;
if (!apiUrl || !apiKey) {
throw new Error("POSTAL_API_URL/POSTAL_API_KEY yok — kazanan maili gönderilemiyor");
}
const fromAddress = process.env.POSTAL_FROM_ADDRESS ?? "noreply@sase.tr";
const fromName = process.env.POSTAL_FROM_NAME ?? "Sase.tr";
const monthLabel = new Intl.DateTimeFormat("tr-TR", {
month: "long",
year: "numeric",
timeZone: "Europe/Istanbul",
}).format(periodStart);
// dev DB'den atılan deneme mailleri prod kapanışlarıyla karışmasın.
const isDev = (process.env.DATABASE_URL ?? "").includes("sase_dev");
const subject = `${isDev ? "[dev] " : ""}Parça Uzmanları ${monthLabel} kazananları — üyelik günlerini tanımlayın`;
const rows = winners
.map(
(w) =>
`<tr><td style="padding:6px 10px">${w.rank}.</td><td style="padding:6px 10px">${escapeHtml(w.name)}</td><td style="padding:6px 10px">${escapeHtml(w.email)}</td><td style="padding:6px 10px;text-align:right">${w.points}</td><td style="padding:6px 10px"><b>${w.days} gün</b></td><td style="padding:6px 10px;font-family:monospace;font-size:12px">${w.userId}</td></tr>`,
)
.join("");
const html = `
<h2>Parça Uzmanları${monthLabel} sezonu kapandı</h2>
<p>Kazananlara üyelik uzatmasını panelden tanımlayın:</p>
<table border="1" cellspacing="0" style="border-collapse:collapse;border-color:#ddd">
<tr><th style="padding:6px 10px">Sıra</th><th style="padding:6px 10px">Ad</th><th style="padding:6px 10px">E-posta</th><th style="padding:6px 10px">Puan</th><th style="padding:6px 10px">Ödül</th><th style="padding:6px 10px">Kullanıcı ID</th></tr>
${rows}
</table>
<p style="color:#888;font-size:12px">Bu mail expert-rewards cron'undan otomatik gönderildi; üyelik günleri otomatik tanımlanmaz.</p>`;
const text = [
`Parça Uzmanları ${monthLabel} kazananları:`,
...winners.map(
(w) => `${w.rank}. ${w.name} <${w.email}> — ${w.points} puan → ${w.days} gün (${w.userId})`,
),
].join("\n");
const response = await fetch(`${apiUrl}/api/v1/send/message`, {
method: "POST",
headers: { "Content-Type": "application/json", "X-Server-API-Key": apiKey },
body: JSON.stringify({
to: [ADMIN_EMAIL],
from: `${fromName} <${fromAddress}>`,
subject,
html_body: html,
plain_body: text,
tag: "expert-rewards",
}),
});
const result = (await response.json()) as { status?: string };
if (result.status !== "success") {
throw new Error(`Postal kazanan maili gönderemedi: ${JSON.stringify(result)}`);
}
} }

View File

@@ -191,8 +191,7 @@ function ExpertsPage() {
Oy ver <span className="font-semibold text-muted-foreground">+1</span> · çoğunluğu tuttur{" "} Oy ver <span className="font-semibold text-muted-foreground">+1</span> · çoğunluğu tuttur{" "}
<span className="font-semibold text-muted-foreground">+2 bonus</span> · kodu ilk <span className="font-semibold text-muted-foreground">+2 bonus</span> · kodu ilk
değerlendiren <span className="font-semibold text-muted-foreground">3 puanı</span> kapar · değerlendiren <span className="font-semibold text-muted-foreground">3 puanı</span> kapar ·
sıralama her ayın 1'i 00:00'da sıfırlanır, biten ayın ilk 3'üne üyelik uzatması otomatik sıralama her ayın 1'i 00:00'da sıfırlanır, biten ayın ilk 3'üne üyelik uzatması tanımlanır
tanımlanır
</p> </p>
</div> </div>
); );