From d88b32924e58589e1f9973a9bef03c0f7ccc8232 Mon Sep 17 00:00:00 2001 From: arsam Date: Fri, 18 Sep 2026 23:20:20 +0330 Subject: [PATCH] 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. --- app/backend/src/routes/monetization.ts | 227 +++++++++++++++++++++++++ 1 file changed, 227 insertions(+) create mode 100644 app/backend/src/routes/monetization.ts diff --git a/app/backend/src/routes/monetization.ts b/app/backend/src/routes/monetization.ts new file mode 100644 index 0000000..a05bfcf --- /dev/null +++ b/app/backend/src/routes/monetization.ts @@ -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 = { 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, + }, + ], + }; + }); +}