Import source: app/backend/scripts/publish-due.mjs
Import the existing local project snapshot. This commit organizes existing files; it does not represent newly developed functionality.
This commit is contained in:
@@ -0,0 +1,199 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { Redis } from 'ioredis';
|
||||||
|
import webpush from 'web-push';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
const redis = new Redis(process.env.REDIS_URL, { lazyConnect: true, maxRetriesPerRequest: 2 });
|
||||||
|
const PUBLISHED = 'منتشرشده';
|
||||||
|
const SCHEDULED = 'زمانبندیشده';
|
||||||
|
|
||||||
|
if (process.env.VAPID_PUBLIC_KEY && process.env.VAPID_PRIVATE_KEY) {
|
||||||
|
webpush.setVapidDetails(
|
||||||
|
process.env.VAPID_SUBJECT ?? 'mailto:admin@teknav.ir',
|
||||||
|
process.env.VAPID_PUBLIC_KEY,
|
||||||
|
process.env.VAPID_PRIVATE_KEY,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bust(pattern) {
|
||||||
|
let cursor = '0';
|
||||||
|
do {
|
||||||
|
const [next, keys] = await redis.scan(cursor, 'MATCH', pattern, 'COUNT', 100);
|
||||||
|
cursor = next;
|
||||||
|
if (keys.length > 0) await redis.del(...keys);
|
||||||
|
} while (cursor !== '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendGuestPush(topic, payload) {
|
||||||
|
if (!process.env.VAPID_PUBLIC_KEY || !process.env.VAPID_PRIVATE_KEY) return;
|
||||||
|
const subs = await prisma.pushSubscription.findMany({
|
||||||
|
where: { userId: null, topics: { has: topic } },
|
||||||
|
});
|
||||||
|
if (!subs.length) return;
|
||||||
|
const data = JSON.stringify({
|
||||||
|
title: `مقاله جدید: ${payload.articleTitle}`,
|
||||||
|
body: '',
|
||||||
|
url: `/article/${payload.articleSlug}`,
|
||||||
|
icon: '/icons/icon-192x192.png',
|
||||||
|
});
|
||||||
|
await Promise.allSettled(
|
||||||
|
subs.map((sub) =>
|
||||||
|
webpush
|
||||||
|
.sendNotification({ endpoint: sub.endpoint, keys: { auth: sub.auth, p256dh: sub.p256dh } }, data)
|
||||||
|
.catch(async (err) => {
|
||||||
|
if (err.statusCode === 410) {
|
||||||
|
await prisma.pushSubscription.delete({ where: { endpoint: sub.endpoint } }).catch(() => {});
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Badge types: awarded based on cumulative reading in a topic
|
||||||
|
const BADGE_RULES = [
|
||||||
|
{ badgeType: 'ai_enthusiast', topic: 'ai', minArticles: 10 },
|
||||||
|
{ badgeType: 'security_expert', topic: 'security', minArticles: 10 },
|
||||||
|
{ badgeType: 'data_scientist', topic: 'data', minArticles: 10 },
|
||||||
|
{ badgeType: 'hardware_geek', topic: 'hardware', minArticles: 10 },
|
||||||
|
{ badgeType: 'software_craftsman', topic: 'software', minArticles: 10 },
|
||||||
|
{ badgeType: 'startup_explorer', topic: 'startups', minArticles: 10 },
|
||||||
|
{ badgeType: 'streak_7', topic: null, minStreak: 7 },
|
||||||
|
{ badgeType: 'streak_30', topic: null, minStreak: 30 },
|
||||||
|
];
|
||||||
|
|
||||||
|
async function evaluateBadges() {
|
||||||
|
// Topic-based badges: count unique articles read per category slug
|
||||||
|
for (const rule of BADGE_RULES) {
|
||||||
|
if (rule.topic) {
|
||||||
|
// Find users who have read >= minArticles articles in this topic
|
||||||
|
const category = await prisma.category.findUnique({ where: { slug: rule.topic }, select: { id: true } });
|
||||||
|
if (!category) continue;
|
||||||
|
const eligible = await prisma.readHistory.groupBy({
|
||||||
|
by: ['userId'],
|
||||||
|
where: { article: { categoryId: category.id } },
|
||||||
|
_count: { articleId: true },
|
||||||
|
having: { articleId: { _count: { gte: rule.minArticles } } },
|
||||||
|
});
|
||||||
|
for (const row of eligible) {
|
||||||
|
await prisma.userBadge.upsert({
|
||||||
|
where: { userId_badgeType: { userId: row.userId, badgeType: rule.badgeType } },
|
||||||
|
create: { userId: row.userId, badgeType: rule.badgeType, metadata: { topic: rule.topic, count: row._count.articleId } },
|
||||||
|
update: { metadata: { topic: rule.topic, count: row._count.articleId } },
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
} else if (rule.minStreak) {
|
||||||
|
// Streak-based badges
|
||||||
|
const eligible = await prisma.user.findMany({
|
||||||
|
where: { streakCount: { gte: rule.minStreak } },
|
||||||
|
select: { id: true, streakCount: true },
|
||||||
|
});
|
||||||
|
for (const user of eligible) {
|
||||||
|
await prisma.userBadge.upsert({
|
||||||
|
where: { userId_badgeType: { userId: user.id, badgeType: rule.badgeType } },
|
||||||
|
create: { userId: user.id, badgeType: rule.badgeType, metadata: { streak: user.streakCount } },
|
||||||
|
update: { metadata: { streak: user.streakCount } },
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await redis.connect();
|
||||||
|
const now = new Date();
|
||||||
|
const due = await prisma.article.findMany({
|
||||||
|
where: { status: SCHEDULED, scheduledAt: { lte: now } },
|
||||||
|
include: {
|
||||||
|
author: { select: { userId: true, name: true } },
|
||||||
|
category: { select: { slug: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (due.length > 0) {
|
||||||
|
await prisma.article.updateMany({
|
||||||
|
where: { id: { in: due.map((article) => article.id) } },
|
||||||
|
data: { status: PUBLISHED, publishedAt: now },
|
||||||
|
});
|
||||||
|
await prisma.activityLog.createMany({
|
||||||
|
data: due.map((article) => ({
|
||||||
|
action: 'مقاله زمانبندیشده منتشر شد',
|
||||||
|
target: article.title,
|
||||||
|
type: 'publish',
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
for (const article of due) {
|
||||||
|
await redis.publish('teknav:realtime', JSON.stringify({
|
||||||
|
event: 'activity',
|
||||||
|
data: { type: 'publish', actor: 'system', target: article.title, ts: now.toISOString() },
|
||||||
|
}));
|
||||||
|
|
||||||
|
const notifyUser = async (userId) => {
|
||||||
|
const notification = await prisma.notification.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
type: 'new_article',
|
||||||
|
payload: {
|
||||||
|
articleId: article.id,
|
||||||
|
articleSlug: article.slug,
|
||||||
|
articleTitle: article.title,
|
||||||
|
actorName: article.author.name,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await redis.publish('teknav:realtime', JSON.stringify({
|
||||||
|
event: 'notification',
|
||||||
|
userId,
|
||||||
|
data: {
|
||||||
|
id: notification.id,
|
||||||
|
type: notification.type,
|
||||||
|
payload: notification.payload,
|
||||||
|
read: notification.read,
|
||||||
|
createdAt: notification.createdAt.toISOString(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const notifiedUserIds = new Set();
|
||||||
|
|
||||||
|
// Notify writer followers
|
||||||
|
if (article.author.userId) {
|
||||||
|
const writerFollows = await prisma.writerFollow.findMany({
|
||||||
|
where: { writerId: article.author.userId },
|
||||||
|
select: { followerId: true },
|
||||||
|
});
|
||||||
|
for (const follow of writerFollows) {
|
||||||
|
await notifyUser(follow.followerId);
|
||||||
|
notifiedUserIds.add(follow.followerId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify topic followers (skip users already notified as writer followers)
|
||||||
|
if (article.category?.slug) {
|
||||||
|
const topicFollows = await prisma.topicFollow.findMany({
|
||||||
|
where: { topic: article.category.slug },
|
||||||
|
select: { userId: true },
|
||||||
|
});
|
||||||
|
for (const follow of topicFollows) {
|
||||||
|
if (notifiedUserIds.has(follow.userId)) continue;
|
||||||
|
await notifyUser(follow.userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fan out to guest push subscriptions for this topic
|
||||||
|
await sendGuestPush(article.category.slug, {
|
||||||
|
articleId: article.id,
|
||||||
|
articleSlug: article.slug,
|
||||||
|
articleTitle: article.title,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await Promise.all(['articles:*', 'search:*', 'categories:*', 'tags:*', 'authors:*'].map(bust));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run badge evaluation on every publish-due tick
|
||||||
|
await evaluateBadges();
|
||||||
|
|
||||||
|
console.info(`[publish-due] published ${due.length} article(s)`);
|
||||||
|
} finally {
|
||||||
|
await redis.quit().catch(() => undefined);
|
||||||
|
await prisma.$disconnect();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user