Import source: app/backend/src/routes/monetization

Import the existing local project snapshot. This commit organizes existing files; it does not represent newly developed functionality.
This commit is contained in:
arsam
2026-09-18 23:20:20 +03:30
parent 7176e2096d
commit d88b32924e
+227
View File
@@ -0,0 +1,227 @@
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { z } from 'zod';
import { prisma } from '../db.js';
import { requireAuth } from '../plugins/session.js';
const PUBLISHED = 'published';
const ZARINPAL_REQUEST_URL = process.env.ZARINPAL_SANDBOX === '1'
? 'https://sandbox.zarinpal.com/pg/v4/payment/request.json'
: 'https://payment.zarinpal.com/pg/v4/payment/request.json';
const ZARINPAL_VERIFY_URL = process.env.ZARINPAL_SANDBOX === '1'
? 'https://sandbox.zarinpal.com/pg/v4/payment/verify.json'
: 'https://payment.zarinpal.com/pg/v4/payment/verify.json';
const ZARINPAL_GATEWAY = process.env.ZARINPAL_SANDBOX === '1'
? 'https://sandbox.zarinpal.com/pg/StartPay'
: 'https://www.zarinpal.com/pg/StartPay';
const MEMBERSHIP_AMOUNT = Number(process.env.MEMBERSHIP_PRICE_IRR ?? 5_000_000); // default 500,000 Tomans
const MEMBERSHIP_DAYS = Number(process.env.MEMBERSHIP_DURATION_DAYS ?? 365);
const jobSchema = z.object({
company: z.string().trim().min(2).max(120),
title: z.string().trim().min(3).max(160),
location: z.string().trim().min(2).max(120).default('Remote'),
remote: z.coerce.boolean().default(true),
url: z.string().trim().url().max(500).optional().or(z.literal('')),
contactEmail: z.string().trim().email().max(254).optional().or(z.literal('')),
description: z.string().trim().min(20).max(3000),
});
function badInput(reply: FastifyReply) {
return reply.code(400).send({ error: 'invalid_input' });
}
export default async function monetizationRoutes(app: FastifyInstance) {
app.get('/api/v1/jobs', async () => ({
items: await prisma.jobListing.findMany({
where: { status: PUBLISHED },
orderBy: { createdAt: 'desc' },
take: 50,
}),
}));
app.post('/api/v1/jobs', async (req, reply) => {
const parsed = jobSchema.safeParse(req.body);
if (!parsed.success) return badInput(reply);
const body = parsed.data;
const job = await prisma.jobListing.create({
data: {
company: body.company,
title: body.title,
location: body.location,
remote: body.remote,
url: body.url || null,
contactEmail: body.contactEmail || null,
description: body.description,
status: 'pending',
},
});
return reply.code(201).send({ ok: true, job: { id: job.id, status: job.status } });
});
// Admin: list all job listings with optional status filter
app.get('/api/v1/admin/jobs', { preHandler: requireAuth(['admin', 'editor']) }, async (req) => {
const { status } = req.query as { status?: string };
const where = status ? { status } : {};
const items = await prisma.jobListing.findMany({
where,
orderBy: { createdAt: 'desc' },
take: 200,
});
return { items };
});
// Admin: approve/reject a job listing
const jobModerationSchema = z.object({
status: z.enum(['approved', 'rejected', 'pending']),
featuredUntil: z.string().datetime().optional(),
});
app.patch('/api/v1/admin/jobs/:id', { preHandler: requireAuth(['admin', 'editor']) }, async (req, reply) => {
const { id } = req.params as { id: string };
const parsed = jobModerationSchema.safeParse(req.body);
if (!parsed.success) return badInput(reply);
const data: Record<string, unknown> = { status: parsed.data.status };
if (parsed.data.featuredUntil) data.featuredUntil = new Date(parsed.data.featuredUntil);
const job = await prisma.jobListing.update({ where: { id }, data }).catch(() => null);
if (!job) return reply.code(404).send({ error: 'not_found' });
return { ok: true, job: { id: job.id, status: job.status } };
});
// ── Membership subscription via Zarinpal ──────────────────────────────────
app.post('/api/v1/membership/subscribe', { preHandler: requireAuth() }, async (req: FastifyRequest, reply) => {
const merchantId = process.env.ZARINPAL_MERCHANT_ID;
if (!merchantId) return reply.code(503).send({ error: 'payment_unavailable' });
const callbackBase = process.env.OAUTH_CALLBACK_BASE ?? 'https://www.teknav.ir';
const callbackUrl = `${callbackBase}/api/membership/callback`;
const zarinRes = await fetch(ZARINPAL_REQUEST_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({
merchant_id: merchantId,
amount: MEMBERSHIP_AMOUNT,
callback_url: callbackUrl,
description: 'عضویت پریمیوم تکناو',
metadata: { userId: req.session!.userId },
}),
});
const zarinData = await zarinRes.json() as { data?: { code?: number; authority?: string }; errors?: unknown };
const authority = zarinData.data?.authority;
if (!authority || zarinData.data?.code !== 100) {
req.log.error({ zarinData }, 'zarinpal_request_failed');
return reply.code(502).send({ error: 'payment_gateway_error' });
}
await prisma.membershipPayment.create({
data: {
userId: req.session!.userId,
authority,
amount: MEMBERSHIP_AMOUNT,
durationDays: MEMBERSHIP_DAYS,
status: 'pending',
},
});
return { redirectUrl: `${ZARINPAL_GATEWAY}/${authority}` };
});
app.get('/api/v1/membership/callback', async (req: FastifyRequest, reply) => {
const { Authority, Status } = req.query as { Authority?: string; Status?: string };
const frontendBase = process.env.OAUTH_CALLBACK_BASE ?? 'https://www.teknav.ir';
if (!Authority || Status !== 'OK') {
return reply.redirect(`${frontendBase}/membership?payment=cancelled`);
}
const payment = await prisma.membershipPayment.findUnique({ where: { authority: Authority } });
if (!payment || payment.status !== 'pending') {
return reply.redirect(`${frontendBase}/membership?payment=invalid`);
}
const merchantId = process.env.ZARINPAL_MERCHANT_ID ?? '';
const verifyRes = await fetch(ZARINPAL_VERIFY_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ merchant_id: merchantId, amount: payment.amount, authority: Authority }),
});
const verifyData = await verifyRes.json() as { data?: { code?: number; ref_id?: number } };
const code = verifyData.data?.code;
if (code !== 100 && code !== 101) {
await prisma.membershipPayment.update({ where: { authority: Authority }, data: { status: 'failed' } });
return reply.redirect(`${frontendBase}/membership?payment=failed`);
}
const refId = String(verifyData.data?.ref_id ?? '');
const expiresAt = new Date(Date.now() + payment.durationDays * 24 * 60 * 60 * 1000);
await prisma.$transaction([
prisma.membershipPayment.update({
where: { authority: Authority },
data: { status: 'paid', refId },
}),
prisma.user.update({
where: { id: payment.userId },
data: { membershipTier: 'premium', membershipExpiresAt: expiresAt },
}),
prisma.notification.create({
data: {
userId: payment.userId,
type: 'system',
payload: {
title: 'عضویت ویژه فعال شد',
body: `عضویت پریمیوم تکناو تا ${expiresAt.toLocaleDateString('fa-IR')} فعال است.`,
refId,
},
},
}),
]);
return reply.redirect(`${frontendBase}/membership/success?ref=${refId}`);
});
app.get('/api/v1/membership/status', { preHandler: requireAuth() }, async (req: FastifyRequest) => {
const user = await prisma.user.findUnique({
where: { id: req.session!.userId },
select: { membershipTier: true, membershipExpiresAt: true },
});
const active = user?.membershipTier === 'premium' &&
(!user.membershipExpiresAt || user.membershipExpiresAt > new Date());
return {
tier: user?.membershipTier ?? 'free',
active,
expiresAt: user?.membershipExpiresAt?.toISOString() ?? null,
};
});
app.get('/api/v1/courses', async () => {
const rows = await prisma.course.findMany({
where: { status: PUBLISHED },
orderBy: { createdAt: 'desc' },
take: 50,
});
if (rows.length > 0) return { items: rows };
return {
items: [
{
id: 'course-ai-production',
slug: 'ai-production',
title: 'استقرار هوش مصنوعی در محصول',
summary: 'دوره عملی طراحی، ارزیابی و انتشار قابلیت‌های AI برای تیم‌های فارسی‌زبان.',
level: 'میانی تا پیشرفته',
price: 0,
status: PUBLISHED,
},
{
id: 'course-security-engineering',
slug: 'security-engineering',
title: 'امنیت کاربردی برای تیم‌های نرم‌افزار',
summary: 'از مدل تهدید تا امن‌سازی API، نشست‌ها، لاگ و واکنش به رخداد.',
level: 'میانی',
price: 0,
status: PUBLISHED,
},
],
};
});
}