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
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:
@@ -745,12 +745,12 @@ export const oemSuggestions = pgTable(
|
||||
|
||||
// ─── OEM Expert Rewards (monthly leaderboard prizes) ──
|
||||
// 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
|
||||
// subscription extension (30/15/7 days — EXPERT_REWARD_LADDER). The unique
|
||||
// Europe/Istanbul) closes the finished season, e-mails the top 3 voters to
|
||||
// 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
|
||||
// inserts nothing, so days are never granted twice. Granting mirrors the
|
||||
// referral mechanic: extend a live active/trial sub, else bank the days in
|
||||
// users.referral_credit_days (consumed at next trial/activation).
|
||||
// inserts nothing and sends no second mail.
|
||||
export const oemExpertRewards = pgTable(
|
||||
"oem_expert_rewards",
|
||||
{
|
||||
|
||||
@@ -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 —
|
||||
// biten ayın ilk 3 oylayıcısına üyelik uzatması (30/15/7 gün). Ödül
|
||||
// yalnız DB'ye yazar (dış yan etki yok), o yüzden dev'de de çalışır;
|
||||
// (period, rank) unique index'i çift vermeyi zaten engeller.
|
||||
// biten ayın ilk 3'ünü admin@sase.tr'ye mailler (üyelik günlerini admin
|
||||
// elle tanımlar) + denetim satırı yazar. Dev'de de çalışır; dev maili
|
||||
// "[dev]" konu önekiyle ayrışır, (period, rank) unique index'i aynı
|
||||
// dönem için ikinci maili/satırı engeller.
|
||||
await this.expertRewardsQueue.upsertJobScheduler(
|
||||
"expert-rewards-monthly",
|
||||
{ pattern: "0 0 1 * *", tz: "Europe/Istanbul" },
|
||||
|
||||
@@ -1,24 +1,34 @@
|
||||
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 {
|
||||
oemExpertRewards,
|
||||
oemVotePoints,
|
||||
userSubscriptions,
|
||||
users,
|
||||
} from "../../database/schema/core";
|
||||
import { oemExpertRewards, oemVotePoints, users } from "../../database/schema/core";
|
||||
import { EXPERT_REWARD_LADDER, previousTrMonthWindow } from "../../oem-votes/expert-period";
|
||||
|
||||
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
|
||||
// oylayıcısına üyelik uzatması (30/15/7 gün). oem_expert_rewards'taki
|
||||
// (period, rank) unique index run-once garantisidir — yeniden çalıştırma
|
||||
// (retry, elle tetik) hiçbir şeyi ikinci kez vermez.
|
||||
// oylayıcısını hesaplar, kazananları admin@sase.tr'ye MAİLLER ve
|
||||
// oem_expert_rewards'a denetim satırı yazar. Üyelik uzatması OTOMATİK
|
||||
// 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(
|
||||
job: Job,
|
||||
db: Database,
|
||||
): Promise<{ period: string; granted: number; skipped: boolean }> {
|
||||
): Promise<{ period: string; winners: number; skipped: boolean }> {
|
||||
const { start, end } = previousTrMonthWindow(new Date());
|
||||
const periodLabel = start.toISOString();
|
||||
console.log(`[expert-rewards] Processing job ${job.id} for period ${periodLabel}`);
|
||||
@@ -30,85 +40,119 @@ export async function processExpertRewards(
|
||||
.limit(1);
|
||||
if (alreadyGranted) {
|
||||
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,
|
||||
// eşitlikte puana daha erken ulaşan önde.
|
||||
const totalPoints = sql<number>`sum(${oemVotePoints.points})::int`;
|
||||
const top = await db
|
||||
.select({ userId: oemVotePoints.userId, points: totalPoints })
|
||||
.select({
|
||||
userId: oemVotePoints.userId,
|
||||
name: users.name,
|
||||
email: users.email,
|
||||
points: totalPoints,
|
||||
})
|
||||
.from(oemVotePoints)
|
||||
.innerJoin(users, eq(users.id, oemVotePoints.userId))
|
||||
.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})`))
|
||||
.limit(EXPERT_REWARD_LADDER.length);
|
||||
|
||||
if (top.length === 0) {
|
||||
console.log(`[expert-rewards] No votes in period ${periodLabel} — nothing to grant`);
|
||||
return { period: periodLabel, granted: 0, skipped: false };
|
||||
console.log(`[expert-rewards] No votes in period ${periodLabel} — nothing to report`);
|
||||
return { period: periodLabel, winners: 0, skipped: false };
|
||||
}
|
||||
|
||||
let granted = 0;
|
||||
for (let i = 0; i < top.length; i++) {
|
||||
const winner = top[i];
|
||||
const { rank, days } = EXPERT_REWARD_LADDER[i];
|
||||
const winners: SeasonWinner[] = top.map((row, i) => ({
|
||||
rank: EXPERT_REWARD_LADDER[i].rank,
|
||||
days: EXPERT_REWARD_LADDER[i].days,
|
||||
userId: row.userId,
|
||||
name: row.name,
|
||||
email: row.email,
|
||||
points: row.points,
|
||||
}));
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
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;
|
||||
await sendWinnersMail(start, winners);
|
||||
|
||||
// Referral ödül mekaniğinin birebir kopyası (worker Nest DI'sız çalıştığı
|
||||
// için ReferralsService.grantRewardDays buradan çağrılamıyor): canlı
|
||||
// active/trial aboneliği uzat, yoksa günleri krediye banka et — kredi bir
|
||||
// sonraki trial/aktivasyonda otomatik tüketilir.
|
||||
const [sub] = await tx
|
||||
.select({ id: userSubscriptions.id, endDate: userSubscriptions.endDate })
|
||||
.from(userSubscriptions)
|
||||
.where(
|
||||
and(
|
||||
eq(userSubscriptions.userId, winner.userId),
|
||||
or(eq(userSubscriptions.status, "active"), eq(userSubscriptions.status, "trial")),
|
||||
),
|
||||
)
|
||||
.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`,
|
||||
);
|
||||
});
|
||||
for (const winner of winners) {
|
||||
await db
|
||||
.insert(oemExpertRewards)
|
||||
.values({
|
||||
periodStart: start,
|
||||
userId: winner.userId,
|
||||
rank: winner.rank,
|
||||
points: winner.points,
|
||||
rewardDays: winner.days,
|
||||
})
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
|
||||
console.log(`[expert-rewards] Period ${periodLabel}: granted ${granted} reward(s)`);
|
||||
return { period: periodLabel, granted, skipped: false };
|
||||
console.log(
|
||||
`[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, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
|
||||
// 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)}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,8 +191,7 @@ function ExpertsPage() {
|
||||
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
|
||||
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
|
||||
tanımlanır
|
||||
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
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user