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) ──
|
// ─── 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",
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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" },
|
||||||
|
|||||||
@@ -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, "&").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{" "}
|
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>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user