@@ -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 gran t ` ) ;
return { period : periodLabel , granted : 0 , skipped : false } ;
console . log ( ` [expert-rewards] No votes in period ${ periodLabel } — nothing to repor t ` ) ;
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 ) } ` ) ;
}
}