feat(gamification): aylık uzman sezonları — ilk 3'e otomatik üyelik uzatması
Parça Uzmanları sıralaması artık aylık sezon: liderlik tablosu içinde
bulunulan TR-ayının (Europe/Istanbul) puanlarını gösterir ve her ayın
1'i 00:00 TR'de kendiliğinden sıfırlanır. expert-rewards cron'u
(BullMQ scheduler, "0 0 1 * *" tz=Europe/Istanbul) aynı anda biten
sezonu kapatır ve ilk 3 oylayıcıya üyelik uzatması verir:
1. → 30 gün, 2. → 15 gün, 3. → 7 gün (EXPERT_REWARD_LADDER).
Ödül mekaniği referral'la birebir: canlı active/trial abonelik endDate
+gün uzar, yoksa günler users.referral_credit_days'e bankalanır (sonraki
trial/aktivasyonda tüketilir). oem_expert_rewards (migration 0017,
period+rank UNIQUE) hem denetim kaydı hem run-once garantisi — retry ya
da elle tetik çift ödül veremez. Sıralama ölçütü job ve leaderboard'da
birebir aynı (puan desc, eşitlikte puana erken ulaşan önde).
Web: uzmanlar sayfasına sezon şeridi ("Haziran 2026 sezonu" + 🥇1 ay ·
🥈15 gün · 🥉7 gün rozetleri); alt küçük-punto kural satırına aylık
sıfırlama + ödül notu eklendi. Leaderboard cevabına periodStart eklendi.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
18
apps/api/drizzle/0017_oem_expert_rewards.sql
Normal file
18
apps/api/drizzle/0017_oem_expert_rewards.sql
Normal file
@@ -0,0 +1,18 @@
|
||||
-- Monthly Parça Uzmanları prizes: one row per (TR-month, rank). Written by the
|
||||
-- expert-rewards cron (1st of month 00:00 Europe/Istanbul) when it closes the
|
||||
-- finished season and grants the top 3 voters a subscription extension
|
||||
-- (30/15/7 days). The unique index is the run-once guard — a re-run for the
|
||||
-- same period inserts nothing, so days are never granted twice.
|
||||
CREATE TABLE "oem_expert_rewards" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"period_start" timestamp with time zone NOT NULL,
|
||||
"user_id" uuid NOT NULL,
|
||||
"rank" integer NOT NULL,
|
||||
"points" integer NOT NULL,
|
||||
"reward_days" integer NOT NULL,
|
||||
"granted_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "oem_expert_rewards" ADD CONSTRAINT "oem_expert_rewards_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "oem_expert_rewards_period_rank_idx" ON "oem_expert_rewards" USING btree ("period_start","rank");--> statement-breakpoint
|
||||
CREATE INDEX "oem_expert_rewards_user_id_idx" ON "oem_expert_rewards" USING btree ("user_id");
|
||||
@@ -120,6 +120,13 @@
|
||||
"when": 1781308800000,
|
||||
"tag": "0016_oem_suggestions",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 17,
|
||||
"version": "7",
|
||||
"when": 1781395200000,
|
||||
"tag": "0017_oem_expert_rewards",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -742,3 +742,31 @@ export const oemSuggestions = pgTable(
|
||||
index("oem_suggestions_oem_code_idx").on(table.oemCode),
|
||||
],
|
||||
);
|
||||
|
||||
// ─── 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
|
||||
// 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).
|
||||
export const oemExpertRewards = pgTable(
|
||||
"oem_expert_rewards",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
// TR-ayının başlangıcı (UTC timestamptz) — ödüllendirilen sezon.
|
||||
periodStart: timestamp("period_start", { withTimezone: true }).notNull(),
|
||||
userId: uuid("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
rank: integer("rank").notNull(),
|
||||
points: integer("points").notNull(),
|
||||
rewardDays: integer("reward_days").notNull(),
|
||||
grantedAt: timestamp("granted_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("oem_expert_rewards_period_rank_idx").on(table.periodStart, table.rank),
|
||||
index("oem_expert_rewards_user_id_idx").on(table.userId),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -27,4 +27,5 @@ export const QUEUE_NAMES = {
|
||||
CATALOG_PREFETCH: "catalog-prefetch",
|
||||
TRANSLATION: "translation",
|
||||
LIFECYCLE_EMAIL: "lifecycle-email",
|
||||
EXPERT_REWARDS: "expert-rewards",
|
||||
} as const;
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
CatalogPrefetchQueueProvider,
|
||||
} from "./queues/catalog-prefetch.queue";
|
||||
import { EMEX_SCRAPE_QUEUE, EmexScrapeQueueProvider } from "./queues/emex-scrape.queue";
|
||||
import { EXPERT_REWARDS_QUEUE, ExpertRewardsQueueProvider } from "./queues/expert-rewards.queue";
|
||||
import { LIFECYCLE_EMAIL_QUEUE, LifecycleEmailQueueProvider } from "./queues/lifecycle-email.queue";
|
||||
import { QUERY_CLEANUP_QUEUE, QueryCleanupQueueProvider } from "./queues/query-cleanup.queue";
|
||||
import {
|
||||
@@ -23,6 +24,7 @@ import {
|
||||
QueryCleanupQueueProvider,
|
||||
CatalogPrefetchQueueProvider,
|
||||
LifecycleEmailQueueProvider,
|
||||
ExpertRewardsQueueProvider,
|
||||
PrefetchWorkerService,
|
||||
],
|
||||
exports: [
|
||||
@@ -31,6 +33,7 @@ import {
|
||||
QUERY_CLEANUP_QUEUE,
|
||||
CATALOG_PREFETCH_QUEUE,
|
||||
LIFECYCLE_EMAIL_QUEUE,
|
||||
EXPERT_REWARDS_QUEUE,
|
||||
],
|
||||
})
|
||||
export class JobsModule implements OnModuleInit, OnModuleDestroy {
|
||||
@@ -39,6 +42,7 @@ export class JobsModule implements OnModuleInit, OnModuleDestroy {
|
||||
@Inject(QUERY_CLEANUP_QUEUE) private queryCleanupQueue: Queue,
|
||||
@Inject(CATALOG_PREFETCH_QUEUE) private catalogPrefetchQueue: Queue,
|
||||
@Inject(LIFECYCLE_EMAIL_QUEUE) private lifecycleEmailQueue: Queue,
|
||||
@Inject(EXPERT_REWARDS_QUEUE) private expertRewardsQueue: Queue,
|
||||
) {}
|
||||
|
||||
async onModuleInit() {
|
||||
@@ -124,6 +128,24 @@ export class JobsModule implements OnModuleInit, OnModuleDestroy {
|
||||
await this.catalogPrefetchQueue.removeJobScheduler("catalog-backfill-hourly").catch(() => {});
|
||||
console.log("[jobs] Skipped catalog-backfill cron (not prod host)");
|
||||
}
|
||||
|
||||
// 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.
|
||||
await this.expertRewardsQueue.upsertJobScheduler(
|
||||
"expert-rewards-monthly",
|
||||
{ pattern: "0 0 1 * *", tz: "Europe/Istanbul" },
|
||||
{
|
||||
name: "expert-rewards-grant",
|
||||
data: {},
|
||||
opts: {
|
||||
removeOnComplete: { count: 24 },
|
||||
removeOnFail: { count: 50 },
|
||||
},
|
||||
},
|
||||
);
|
||||
console.log("[jobs] Registered expert-rewards cron: 0 0 1 * * (Europe/Istanbul)");
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
@@ -132,6 +154,7 @@ export class JobsModule implements OnModuleInit, OnModuleDestroy {
|
||||
this.queryCleanupQueue.close(),
|
||||
this.catalogPrefetchQueue.close(),
|
||||
this.lifecycleEmailQueue.close(),
|
||||
this.expertRewardsQueue.close(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
114
apps/api/src/jobs/processors/expert-rewards.processor.ts
Normal file
114
apps/api/src/jobs/processors/expert-rewards.processor.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { Job } from "bullmq";
|
||||
import { and, asc, desc, eq, gte, lt, or, sql } from "drizzle-orm";
|
||||
import { PostgresJsDatabase } from "drizzle-orm/postgres-js";
|
||||
import {
|
||||
oemExpertRewards,
|
||||
oemVotePoints,
|
||||
userSubscriptions,
|
||||
users,
|
||||
} from "../../database/schema/core";
|
||||
import { EXPERT_REWARD_LADDER, previousTrMonthWindow } from "../../oem-votes/expert-period";
|
||||
|
||||
type Database = PostgresJsDatabase<Record<string, unknown>>;
|
||||
|
||||
// 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.
|
||||
export async function processExpertRewards(
|
||||
job: Job,
|
||||
db: Database,
|
||||
): Promise<{ period: string; granted: number; skipped: boolean }> {
|
||||
const { start, end } = previousTrMonthWindow(new Date());
|
||||
const periodLabel = start.toISOString();
|
||||
console.log(`[expert-rewards] Processing job ${job.id} for period ${periodLabel}`);
|
||||
|
||||
const [alreadyGranted] = await db
|
||||
.select({ id: oemExpertRewards.id })
|
||||
.from(oemExpertRewards)
|
||||
.where(eq(oemExpertRewards.periodStart, start))
|
||||
.limit(1);
|
||||
if (alreadyGranted) {
|
||||
console.log(`[expert-rewards] Period ${periodLabel} already granted — skipping`);
|
||||
return { period: periodLabel, granted: 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 })
|
||||
.from(oemVotePoints)
|
||||
.where(and(gte(oemVotePoints.createdAt, start), lt(oemVotePoints.createdAt, end)))
|
||||
.groupBy(oemVotePoints.userId)
|
||||
.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 };
|
||||
}
|
||||
|
||||
let granted = 0;
|
||||
for (let i = 0; i < top.length; i++) {
|
||||
const winner = top[i];
|
||||
const { rank, days } = EXPERT_REWARD_LADDER[i];
|
||||
|
||||
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;
|
||||
|
||||
// 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`,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`[expert-rewards] Period ${periodLabel}: granted ${granted} reward(s)`);
|
||||
return { period: periodLabel, granted, skipped: false };
|
||||
}
|
||||
@@ -67,17 +67,11 @@ async function sendTrialEnding(db: Database, now: Date): Promise<number> {
|
||||
.innerJoin(users, eq(userSubscriptions.userId, users.id))
|
||||
.leftJoin(
|
||||
emailPreferences,
|
||||
and(
|
||||
eq(emailPreferences.userId, users.id),
|
||||
eq(emailPreferences.workflow, "trial-ending"),
|
||||
),
|
||||
and(eq(emailPreferences.userId, users.id), eq(emailPreferences.workflow, "trial-ending")),
|
||||
)
|
||||
.leftJoin(
|
||||
lifecycleEmailSent,
|
||||
and(
|
||||
eq(lifecycleEmailSent.userId, users.id),
|
||||
eq(lifecycleEmailSent.workflow, "trial-ending"),
|
||||
),
|
||||
and(eq(lifecycleEmailSent.userId, users.id), eq(lifecycleEmailSent.workflow, "trial-ending")),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
@@ -96,11 +90,7 @@ async function sendTrialEnding(db: Database, now: Date): Promise<number> {
|
||||
{ subscriberId: r.userId, email: r.email, firstName: firstNameOf(r.name) },
|
||||
{
|
||||
daysLeft: 3,
|
||||
ctaUrl: buildTrackedUrl(
|
||||
"trial-ending",
|
||||
r.email,
|
||||
webUrl("/dashboard/subscription"),
|
||||
),
|
||||
ctaUrl: buildTrackedUrl("trial-ending", r.email, webUrl("/dashboard/subscription")),
|
||||
...(trackPixel ? { trackPixel } : {}),
|
||||
},
|
||||
);
|
||||
@@ -134,17 +124,11 @@ async function sendWinBack(db: Database, now: Date): Promise<number> {
|
||||
.innerJoin(users, eq(userSubscriptions.userId, users.id))
|
||||
.leftJoin(
|
||||
emailPreferences,
|
||||
and(
|
||||
eq(emailPreferences.userId, users.id),
|
||||
eq(emailPreferences.workflow, "win-back"),
|
||||
),
|
||||
and(eq(emailPreferences.userId, users.id), eq(emailPreferences.workflow, "win-back")),
|
||||
)
|
||||
.leftJoin(
|
||||
lifecycleEmailSent,
|
||||
and(
|
||||
eq(lifecycleEmailSent.userId, users.id),
|
||||
eq(lifecycleEmailSent.workflow, "win-back"),
|
||||
),
|
||||
and(eq(lifecycleEmailSent.userId, users.id), eq(lifecycleEmailSent.workflow, "win-back")),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
|
||||
25
apps/api/src/jobs/queues/expert-rewards.queue.ts
Normal file
25
apps/api/src/jobs/queues/expert-rewards.queue.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Provider } from "@nestjs/common";
|
||||
import { Queue } from "bullmq";
|
||||
import { QUEUE_NAMES, getBullConnection, getBullTelemetry } from "../bull.config";
|
||||
|
||||
export const EXPERT_REWARDS_QUEUE = "EXPERT_REWARDS_QUEUE";
|
||||
|
||||
export const ExpertRewardsQueueProvider: Provider = {
|
||||
provide: EXPERT_REWARDS_QUEUE,
|
||||
useFactory: () => {
|
||||
const telemetry = getBullTelemetry();
|
||||
return new Queue(QUEUE_NAMES.EXPERT_REWARDS, {
|
||||
connection: getBullConnection(),
|
||||
...(telemetry ? { telemetry } : {}),
|
||||
defaultJobOptions: {
|
||||
attempts: 3,
|
||||
backoff: {
|
||||
type: "exponential",
|
||||
delay: 10000,
|
||||
},
|
||||
removeOnComplete: { count: 100 },
|
||||
removeOnFail: { count: 200 },
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
37
apps/api/src/oem-votes/expert-period.spec.ts
Normal file
37
apps/api/src/oem-votes/expert-period.spec.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { EXPERT_REWARD_LADDER, previousTrMonthWindow, trMonthStart } from "./expert-period";
|
||||
|
||||
describe("trMonthStart", () => {
|
||||
it("ay ortasında TR-ayının başını UTC olarak verir (TR 00:00 = UTC 21:00 önceki gün)", () => {
|
||||
const now = new Date("2026-06-15T10:00:00Z");
|
||||
expect(trMonthStart(now).toISOString()).toBe("2026-05-31T21:00:00.000Z");
|
||||
});
|
||||
|
||||
it("UTC gece TR'de yeni aya geçmişse TR gününü baz alır", () => {
|
||||
// UTC 30 Haziran 22:30 = TR 1 Temmuz 01:30 → içinde bulunulan TR-ayı Temmuz
|
||||
const now = new Date("2026-06-30T22:30:00Z");
|
||||
expect(trMonthStart(now).toISOString()).toBe("2026-06-30T21:00:00.000Z");
|
||||
expect(trMonthStart(now, 1).toISOString()).toBe("2026-05-31T21:00:00.000Z");
|
||||
});
|
||||
|
||||
it("yıl devrini doğru çözer (TR ocak → geriye aralık)", () => {
|
||||
const now = new Date("2026-01-10T12:00:00Z");
|
||||
expect(trMonthStart(now, 1).toISOString()).toBe("2025-11-30T21:00:00.000Z");
|
||||
});
|
||||
});
|
||||
|
||||
describe("previousTrMonthWindow", () => {
|
||||
it("cron anında (TR 1'i 00:00) biten ayı kapsar", () => {
|
||||
// TR 1 Temmuz 00:00 = UTC 30 Haziran 21:00
|
||||
const cronMoment = new Date("2026-06-30T21:00:00Z");
|
||||
const { start, end } = previousTrMonthWindow(cronMoment);
|
||||
expect(start.toISOString()).toBe("2026-05-31T21:00:00.000Z"); // TR 1 Haziran 00:00
|
||||
expect(end.toISOString()).toBe("2026-06-30T21:00:00.000Z"); // TR 1 Temmuz 00:00
|
||||
});
|
||||
});
|
||||
|
||||
describe("EXPERT_REWARD_LADDER", () => {
|
||||
it("1. → 30 gün, 2. → 15 gün, 3. → 7 gün", () => {
|
||||
expect(EXPERT_REWARD_LADDER.map((r) => r.days)).toEqual([30, 15, 7]);
|
||||
});
|
||||
});
|
||||
23
apps/api/src/oem-votes/expert-period.ts
Normal file
23
apps/api/src/oem-votes/expert-period.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
// Parça Uzmanları aylık sezon takvimi. Sınırlar Türkiye saatine göredir
|
||||
// (Europe/Istanbul, 2016'dan beri sabit UTC+3 — DST yok); sıralama her ayın
|
||||
// 1'i 00:00 TR'de sıfırlanır, biten ayın ilk 3'üne üyelik uzatması verilir.
|
||||
const TR_OFFSET_MS = 3 * 60 * 60 * 1000;
|
||||
|
||||
// Ödül merdiveni: 1. → 1 ay (30 gün), 2. → 15 gün, 3. → 7 gün üyelik.
|
||||
export const EXPERT_REWARD_LADDER = [
|
||||
{ rank: 1, days: 30 },
|
||||
{ rank: 2, days: 15 },
|
||||
{ rank: 3, days: 7 },
|
||||
] as const;
|
||||
|
||||
// `monthsBack` ay geriden TR-ayının başlangıcını UTC Date olarak döndürür
|
||||
// (0 = içinde bulunulan ay). Date.UTC ay taşmalarını (ocak-1 → aralık) çözer.
|
||||
export function trMonthStart(now: Date, monthsBack = 0): Date {
|
||||
const tr = new Date(now.getTime() + TR_OFFSET_MS);
|
||||
return new Date(Date.UTC(tr.getUTCFullYear(), tr.getUTCMonth() - monthsBack, 1) - TR_OFFSET_MS);
|
||||
}
|
||||
|
||||
// Biten sezonun penceresi: [önceki TR-ay başı, bu TR-ay başı).
|
||||
export function previousTrMonthWindow(now: Date): { start: Date; end: Date } {
|
||||
return { start: trMonthStart(now, 1), end: trMonthStart(now, 0) };
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Inject, Injectable } from "@nestjs/common";
|
||||
import { and, count, desc, eq, inArray, sql } from "drizzle-orm";
|
||||
import { and, count, desc, eq, gte, inArray, sql } from "drizzle-orm";
|
||||
import { DATABASE, type Database } from "../database/database.provider";
|
||||
import { oemVotePoints, oemVotes, users } from "../database/schema/core";
|
||||
import { trMonthStart } from "./expert-period";
|
||||
import {
|
||||
type OemVoteChoice,
|
||||
type VoteCounts,
|
||||
@@ -149,12 +150,18 @@ export class OemVotesService {
|
||||
return result;
|
||||
}
|
||||
|
||||
// Parça Uzmanları: puana göre herkes, adlar maskeli ("S*** Y***"). Yanıt
|
||||
// userId sızdırmaz; istek sahibi kendi satırını isMe ile bulur.
|
||||
// Parça Uzmanları: içinde bulunulan TR-ayının (sezonun) sıralaması — her
|
||||
// ayın 1'i 00:00 TR'de sıfırlanır, biten ayın ilk 3'üne expert-rewards
|
||||
// cron'u üyelik uzatması verir. Puana göre herkes, adlar maskeli
|
||||
// ("S*** Y***"); yanıt userId sızdırmaz, istek sahibi kendi satırını isMe
|
||||
// ile bulur. Sıralama ödül job'ıyla birebir aynı: puan desc, eşitlikte
|
||||
// puana daha erken ulaşan önde.
|
||||
async leaderboard(userId: string): Promise<{
|
||||
entries: LeaderboardEntry[];
|
||||
me: { rank: number | null; points: number; votes: number };
|
||||
periodStart: string;
|
||||
}> {
|
||||
const periodStart = trMonthStart(new Date());
|
||||
const totalPoints = sql<number>`sum(${oemVotePoints.points})::int`;
|
||||
const rows = await this.db
|
||||
.select({
|
||||
@@ -165,6 +172,7 @@ export class OemVotesService {
|
||||
})
|
||||
.from(oemVotePoints)
|
||||
.innerJoin(users, eq(users.id, oemVotePoints.userId))
|
||||
.where(gte(oemVotePoints.createdAt, periodStart))
|
||||
.groupBy(oemVotePoints.userId, users.name)
|
||||
.orderBy(desc(totalPoints), sql`min(${oemVotePoints.createdAt}) asc`)
|
||||
.limit(500);
|
||||
@@ -180,6 +188,7 @@ export class OemVotesService {
|
||||
return {
|
||||
entries,
|
||||
me: { rank: my?.rank ?? null, points: my?.points ?? 0, votes: my?.votes ?? 0 },
|
||||
periodStart: periodStart.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import OpenAI from "openai";
|
||||
import postgres from "postgres";
|
||||
import { QUEUE_NAMES, getBullConnection, getBullTelemetry } from "./jobs/bull.config";
|
||||
import { processEmexScrape } from "./jobs/processors/emex-scrape.processor";
|
||||
import { processExpertRewards } from "./jobs/processors/expert-rewards.processor";
|
||||
import { processLifecycleEmails } from "./jobs/processors/lifecycle-email.processor";
|
||||
import { processQueryCleanup } from "./jobs/processors/query-cleanup.processor";
|
||||
import { processSubscriptionExpiry } from "./jobs/processors/subscription-expiry.processor";
|
||||
@@ -159,6 +160,32 @@ lifecycleEmailWorker.on("failed", (job, err) => {
|
||||
|
||||
workers.push(lifecycleEmailWorker);
|
||||
|
||||
// Expert Rewards Worker (monthly Parça Uzmanları top-3 subscription prizes)
|
||||
const expertRewardsWorker = new Worker(
|
||||
QUEUE_NAMES.EXPERT_REWARDS,
|
||||
async (job) => {
|
||||
return processExpertRewards(job, db);
|
||||
},
|
||||
{
|
||||
connection,
|
||||
concurrency: 1,
|
||||
...(telemetry ? { telemetry } : {}),
|
||||
},
|
||||
);
|
||||
|
||||
expertRewardsWorker.on("completed", (job) => {
|
||||
console.log(`[worker] expert-rewards job ${job.id} completed`);
|
||||
});
|
||||
|
||||
expertRewardsWorker.on("failed", (job, err) => {
|
||||
console.error(`[worker] expert-rewards job ${job?.id} failed: ${err.message}`);
|
||||
Sentry.captureException(err, {
|
||||
tags: { queue: QUEUE_NAMES.EXPERT_REWARDS, jobId: job?.id },
|
||||
});
|
||||
});
|
||||
|
||||
workers.push(expertRewardsWorker);
|
||||
|
||||
// Translation Worker (async LLM translation for new EMEX/PCAT terms)
|
||||
const openrouterApiKey = process.env.OPENROUTER_API_KEY;
|
||||
if (openrouterApiKey) {
|
||||
|
||||
Reference in New Issue
Block a user