"use strict"; const { getUserContextById, requireAuth, verifyToken } = require("../auth"); const { pool, withTransaction } = require("../db"); const { loadBetaInviteCodes, normalizeBetaInviteCode } = require("../betaInviteCodes"); const REVIEW_USERNAMES = new Set(["xqy1912"]); const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; function cleanText(value, maxLength) { return String(value || "").trim().slice(0, maxLength); } function cleanTextArray(value, maxItems = 20, maxLength = 200) { if (!Array.isArray(value)) return []; return value.map((item) => cleanText(item, maxLength)).filter(Boolean).slice(0, maxItems); } function normalizeEmail(email) { return String(email || "").trim().toLowerCase(); } function validateEmail(email) { const normalized = normalizeEmail(email); if (!normalized) return "请填写用于接收内测码的邮箱"; if (!EMAIL_PATTERN.test(normalized)) return "邮箱格式不正确"; return null; } function parseJson(value, fallback) { if (!value || typeof value !== "string") return fallback; try { return JSON.parse(value); } catch { return fallback; } } function safeJsonString(value, fallback) { try { return JSON.stringify(value ?? fallback); } catch { return JSON.stringify(fallback); } } function buildSmtpTransportOptions(scope) { const prefix = scope ? `${scope}_` : ""; return { host: process.env[`${prefix}SMTP_HOST`] || process.env.SMTP_HOST, port: Number(process.env[`${prefix}SMTP_PORT`] || process.env.SMTP_PORT) || 587, secure: String(process.env[`${prefix}SMTP_SECURE`] || process.env.SMTP_SECURE || "") === "1", auth: { user: process.env[`${prefix}SMTP_USER`] || process.env.SMTP_USER, pass: process.env[`${prefix}SMTP_PASS`] || process.env.SMTP_PASS, }, }; } function formatEmailAddress(address, displayName) { const email = String(address || "").trim(); const name = String(displayName || "").trim(); if (!name) return email; const escapedName = name.replace(/"/g, '\\"'); return `"${escapedName}" <${email}>`; } function getRequestIp(req) { const forwardedFor = String(req.headers["x-forwarded-for"] || "").split(",")[0].trim(); return forwardedFor || req.socket?.remoteAddress || ""; } async function optionalAuth(req, _res, next) { const authHeader = req.headers.authorization; if (!authHeader?.startsWith("Bearer ")) { next(); return; } try { const payload = verifyToken(authHeader.slice(7)); const user = await getUserContextById(payload.userId); if (user?.enabled) req.user = user; } catch { // Public application submission should still work without a valid session. } next(); } function canReviewBetaApplications(user) { if (!user) return false; const role = String(user.role || "").trim().toLowerCase(); const username = String(user.username || "").trim().toLowerCase(); return role === "admin" || REVIEW_USERNAMES.has(username); } function requireBetaApplicationReviewer(req, res, next) { if (!canReviewBetaApplications(req.user)) { return res.status(403).json({ error: "无权审核内测申请" }); } next(); } async function ensureBetaApplicationSchema() { await pool.query(` CREATE TABLE IF NOT EXISTS beta_applications ( id SERIAL PRIMARY KEY, user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, name TEXT, email TEXT, phone TEXT, wechat TEXT, industry TEXT, company TEXT, city TEXT, ai_tools TEXT, ai_duration TEXT, ai_track TEXT, ai_direction_json TEXT NOT NULL DEFAULT '[]', weekly_usage TEXT, feedback_willing TEXT, want_feature_json TEXT NOT NULL DEFAULT '[]', self_statement TEXT, signature TEXT, application_date TEXT, agree_rules INTEGER NOT NULL DEFAULT 0, status TEXT NOT NULL DEFAULT 'pending', invite_code TEXT, review_note TEXT, reviewed_by INTEGER REFERENCES users(id) ON DELETE SET NULL, reviewed_at TIMESTAMPTZ, ip_address TEXT, user_agent TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX IF NOT EXISTS idx_beta_applications_status_created ON beta_applications(status, created_at DESC); CREATE INDEX IF NOT EXISTS idx_beta_applications_user_created ON beta_applications(user_id, created_at DESC); ALTER TABLE beta_applications ADD COLUMN IF NOT EXISTS email TEXT; ALTER TABLE beta_applications ADD COLUMN IF NOT EXISTS application_date TEXT; CREATE INDEX IF NOT EXISTS idx_beta_applications_email ON beta_applications(LOWER(email)); `); } function normalizeApplicationBody(body) { return { name: cleanText(body?.name, 120), email: normalizeEmail(body?.email), phone: cleanText(body?.phone, 60), wechat: cleanText(body?.wechat, 120), industry: cleanText(body?.industry, 160), company: cleanText(body?.company, 200), city: cleanText(body?.city, 120), aiTools: cleanText(body?.aiTools ?? body?.ai_tools, 1000), aiDuration: cleanText(body?.aiDuration ?? body?.ai_duration, 120), aiTrack: cleanText(body?.aiTrack ?? body?.ai_track, 160), aiDirection: cleanTextArray(body?.aiDirection ?? body?.ai_direction), weeklyUsage: cleanText(body?.weeklyUsage ?? body?.weekly_usage, 120), feedbackWilling: cleanText(body?.feedbackWilling ?? body?.feedback_willing, 160), wantFeature: cleanTextArray(body?.wantFeature ?? body?.want_feature), selfStatement: cleanText(body?.selfStatement ?? body?.self_statement, 5000), signature: cleanText(body?.signature, 120), applicationDate: cleanText(body?.applicationDate ?? body?.application_date, 120), agreeRules: body?.agreeRules === true || body?.agree_rules === true || body?.agreeRules === 1 || body?.agree_rules === 1, }; } function formatApplication(row) { return { id: Number(row.id), userId: row.user_id == null ? null : Number(row.user_id), username: row.username || null, name: row.name || "", email: row.email || "", phone: row.phone || "", wechat: row.wechat || "", industry: row.industry || "", company: row.company || "", city: row.city || "", aiTools: row.ai_tools || "", aiDuration: row.ai_duration || "", aiTrack: row.ai_track || "", aiDirection: parseJson(row.ai_direction_json, []), weeklyUsage: row.weekly_usage || "", feedbackWilling: row.feedback_willing || "", wantFeature: parseJson(row.want_feature_json, []), selfStatement: row.self_statement || "", signature: row.signature || "", applicationDate: row.application_date || "", agreeRules: Boolean(row.agree_rules), status: row.status || "pending", inviteCode: row.invite_code || null, reviewNote: row.review_note || null, reviewedBy: row.reviewed_by == null ? null : Number(row.reviewed_by), reviewerUsername: row.reviewer_username || null, reviewedAt: row.reviewed_at || null, ipAddress: row.ip_address || null, userAgent: row.user_agent || null, createdAt: row.created_at, updatedAt: row.updated_at, }; } async function selectApplicationById(client, id) { const { rows } = await client.query( ` SELECT a.*, u.username, reviewer.username AS reviewer_username FROM beta_applications a LEFT JOIN users u ON u.id = a.user_id LEFT JOIN users reviewer ON reviewer.id = a.reviewed_by WHERE a.id = $1 LIMIT 1 `, [id], ); return rows[0] || null; } async function issueNextBetaInviteCode(client) { const codes = Array.from(loadBetaInviteCodes()).map(normalizeBetaInviteCode).filter(Boolean).sort(); for (const code of codes) { const { rows } = await client.query( ` SELECT 1 FROM beta_invite_code_uses WHERE code = $1 UNION ALL SELECT 1 FROM beta_applications WHERE invite_code = $1 AND status = 'approved' LIMIT 1 `, [code], ); if (rows.length === 0) return code; } return null; } async function createNotification(client, userId, input) { if (!userId) return; await client.query( ` INSERT INTO web_notifications ( user_id, type, title, description, target_type, target_id, metadata_json ) VALUES ($1, $2, $3, $4, $5, $6, $7) `, [ userId, input.type, input.title, input.description || null, input.targetType || "beta_application", input.targetId ? String(input.targetId) : null, safeJsonString(input.metadata, {}), ], ); } function buildReviewEmailContent(application, action, inviteCode, reviewNote) { const name = application.name || "内测申请人"; if (action === "approve") { const text = [ `${name},您好:`, "", "您的 OmniAI 内测申请已通过。", `内测码:${inviteCode}`, "", "请在注册页面填写该内测码完成账号注册。内测码仅限本人使用,请勿转发。", "", "OmniAI 团队", ].join("\n"); const html = `
${name},您好:
您的 OmniAI 内测申请已通过。
内测码:${inviteCode}
请在注册页面填写该内测码完成账号注册。内测码仅限本人使用,请勿转发。
OmniAI 团队
${name},您好:
您未通过 OmniAI 内测申请。
审核备注:${reason}
感谢您的关注。
OmniAI 团队