Files
teknav-bak/app/backend/scripts/publish-due.mjs
T
arsam 7ac6dc0b91 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.
2026-09-18 23:19:05 +03:30

200 lines
7.0 KiB
JavaScript

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();
}