Adds console logs to the /api/posts endpoint in server/index.ts to capture incoming payloads and successful post creation data, aiding in debugging. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 9203795e-937a-4306-b81d-b4d5c78c240e Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Event-Id: f65c3e3d-c2a9-489d-b74e-9278ae76aed3 Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/7c94b7a0-29c7-4f2e-94ef-44b2153872b7/9203795e-937a-4306-b81d-b4d5c78c240e/zMxtXds Replit-Helium-Checkpoint-Created: true
6367 lines
201 KiB
TypeScript
6367 lines
201 KiB
TypeScript
import "dotenv/config";
|
||
import "dotenv/config";
|
||
import express from "express";
|
||
import cors from "cors";
|
||
import { adminSupabase } from "./supabase";
|
||
import { emailService } from "./email";
|
||
import { randomUUID, createHash, createVerify, randomBytes } from "crypto";
|
||
import blogIndexHandler from "../api/blog/index";
|
||
import blogSlugHandler from "../api/blog/[slug]";
|
||
|
||
// Discord Interactions Handler
|
||
const handleDiscordInteractions = async (
|
||
req: express.Request,
|
||
res: express.Response,
|
||
) => {
|
||
try {
|
||
const signature = req.get("x-signature-ed25519");
|
||
const timestamp = req.get("x-signature-timestamp");
|
||
const rawBody =
|
||
req.body instanceof Buffer
|
||
? req.body
|
||
: Buffer.from(JSON.stringify(req.body), "utf8");
|
||
const bodyString = rawBody.toString("utf8");
|
||
|
||
const publicKey = process.env.DISCORD_PUBLIC_KEY;
|
||
|
||
console.log("[Discord] Interaction received at", new Date().toISOString());
|
||
|
||
if (!publicKey) {
|
||
console.error("[Discord] DISCORD_PUBLIC_KEY not set");
|
||
return res.status(401).json({ error: "Server not configured" });
|
||
}
|
||
|
||
if (!signature || !timestamp) {
|
||
console.error(
|
||
"[Discord] Missing headers - signature:",
|
||
!!signature,
|
||
"timestamp:",
|
||
!!timestamp,
|
||
);
|
||
return res.status(401).json({ error: "Invalid request" });
|
||
}
|
||
|
||
// Verify signature
|
||
const message = `${timestamp}${bodyString}`;
|
||
const signatureBuffer = Buffer.from(signature, "hex");
|
||
const verifier = createVerify("ed25519");
|
||
verifier.update(message);
|
||
const isValid = verifier.verify(publicKey, signatureBuffer);
|
||
|
||
if (!isValid) {
|
||
console.error("[Discord] Signature verification failed");
|
||
return res.status(401).json({ error: "Invalid signature" });
|
||
}
|
||
|
||
const interaction = JSON.parse(bodyString);
|
||
console.log("[Discord] Valid interaction type:", interaction.type);
|
||
|
||
// Discord sends a PING to verify the endpoint
|
||
if (interaction.type === 1) {
|
||
console.log("[Discord] ✓ PING verified");
|
||
return res.json({ type: 1 });
|
||
}
|
||
|
||
// Handle APPLICATION_COMMAND (slash commands)
|
||
if (interaction.type === 2) {
|
||
const commandName = interaction.data.name;
|
||
console.log("[Discord] Slash command received:", commandName);
|
||
|
||
// /creators command
|
||
if (commandName === "creators") {
|
||
const arm = interaction.data.options?.[0]?.value;
|
||
const armFilter = arm ? ` (${arm})` : " (all arms)";
|
||
|
||
return res.json({
|
||
type: 4,
|
||
data: {
|
||
content: `🔍 Browse AeThex Creators${armFilter}\n\n👉 [Open Creator Directory](https://aethex.dev/creators${arm ? `?arm=${arm}` : ""})`,
|
||
flags: 0,
|
||
},
|
||
});
|
||
}
|
||
|
||
// /opportunities command
|
||
if (commandName === "opportunities") {
|
||
const arm = interaction.data.options?.[0]?.value;
|
||
const armFilter = arm ? ` (${arm})` : " (all arms)";
|
||
|
||
return res.json({
|
||
type: 4,
|
||
data: {
|
||
content: `💼 Find Opportunities on Nexus${armFilter}\n\n👉 [Browse Opportunities](https://aethex.dev/opportunities${arm ? `?arm=${arm}` : ""})`,
|
||
flags: 0,
|
||
},
|
||
});
|
||
}
|
||
|
||
// /nexus command
|
||
if (commandName === "nexus") {
|
||
return res.json({
|
||
type: 4,
|
||
data: {
|
||
content: `✨ **AeThex Nexus** - The Talent Marketplace\n\n🔗 [Open Nexus](https://aethex.dev/nexus)\n\n**Quick Links:**\n• 🔍 [Browse Creators](https://aethex.dev/creators)\n• 💼 [Find Opportunities](https://aethex.dev/opportunities)\n• 📊 [View Metrics](https://aethex.dev/admin)`,
|
||
flags: 0,
|
||
},
|
||
});
|
||
}
|
||
|
||
// /verify command - Generate verification code and link
|
||
if (commandName === "verify") {
|
||
try {
|
||
const discordId = interaction.member?.user?.id;
|
||
|
||
if (!discordId) {
|
||
return res.json({
|
||
type: 4,
|
||
data: {
|
||
content: "❌ Could not get your Discord ID",
|
||
flags: 64,
|
||
},
|
||
});
|
||
}
|
||
|
||
// Generate verification code (random 6-digit)
|
||
const verificationCode = Math.random()
|
||
.toString(36)
|
||
.substring(2, 8)
|
||
.toUpperCase();
|
||
const expiresAt = new Date(Date.now() + 15 * 60 * 1000).toISOString(); // 15 min
|
||
|
||
// Store verification code in Supabase
|
||
const { error } = await adminSupabase
|
||
.from("discord_verifications")
|
||
.insert([
|
||
{
|
||
discord_id: discordId,
|
||
verification_code: verificationCode,
|
||
expires_at: expiresAt,
|
||
},
|
||
]);
|
||
|
||
if (error) {
|
||
console.error("Error storing verification code:", error);
|
||
return res.json({
|
||
type: 4,
|
||
data: {
|
||
content:
|
||
"❌ Error generating verification code. Please try again.",
|
||
flags: 64,
|
||
},
|
||
});
|
||
}
|
||
|
||
const verifyUrl = `https://aethex.dev/discord-verify?code=${verificationCode}`;
|
||
|
||
return res.json({
|
||
type: 4,
|
||
data: {
|
||
content: `✅ **Verification Code: \`${verificationCode}\`**\n\n<EFBFBD><EFBFBD><EFBFBD> [Click here to verify your account](${verifyUrl})\n\n⏱️ This code expires in 15 minutes.`,
|
||
flags: 0,
|
||
},
|
||
});
|
||
} catch (error) {
|
||
console.error("Error in /verify command:", error);
|
||
return res.json({
|
||
type: 4,
|
||
data: {
|
||
content: "<22><> An error occurred. Please try again later.",
|
||
flags: 64,
|
||
},
|
||
});
|
||
}
|
||
}
|
||
|
||
// /set-realm command - Choose primary arm
|
||
if (commandName === "set-realm") {
|
||
const realmChoice = interaction.data.options?.[0]?.value;
|
||
|
||
if (!realmChoice) {
|
||
return res.json({
|
||
type: 4,
|
||
data: {
|
||
content: "❌ Please select a realm",
|
||
flags: 64,
|
||
},
|
||
});
|
||
}
|
||
|
||
const realmMap: any = {
|
||
labs: "🔬 Labs",
|
||
gameforge: "🎮 GameForge",
|
||
corp: "💼 Corp",
|
||
foundation: "🤝 Foundation",
|
||
devlink: "🔗 Dev-Link",
|
||
};
|
||
|
||
return res.json({
|
||
type: 4,
|
||
data: {
|
||
content: `✅ You've selected **${realmMap[realmChoice] || realmChoice}** as your primary realm!\n\n📝 Your role will be assigned based on your selection.`,
|
||
flags: 0,
|
||
},
|
||
});
|
||
}
|
||
|
||
// /profile command - Show user profile
|
||
if (commandName === "profile") {
|
||
const discordId = interaction.member?.user?.id;
|
||
const username = interaction.member?.user?.username;
|
||
|
||
return res.json({
|
||
type: 4,
|
||
data: {
|
||
content: `👤 **Your AeThex Profile**\n\n**Discord:** ${username} (\`${discordId}\`)\n\n🔗 [View Full Profile](https://aethex.dev/profile)\n\n**Quick Actions:**\n• \`/set-realm\` - Choose your primary arm\n• \`/verify\` - Link your account\n• \`/verify-role\` - Check your assigned roles`,
|
||
flags: 0,
|
||
},
|
||
});
|
||
}
|
||
|
||
// /unlink command - Disconnect Discord
|
||
if (commandName === "unlink") {
|
||
const discordId = interaction.member?.user?.id;
|
||
|
||
return res.json({
|
||
type: 4,
|
||
data: {
|
||
content: `<EFBFBD><EFBFBD><EFBFBD> **Account Unlinked**\n\nYour Discord account (\`${discordId}\`) has been disconnected from AeThex.\n\nTo link again, use \`/verify\``,
|
||
flags: 0,
|
||
},
|
||
});
|
||
}
|
||
|
||
// /verify-role command - Check assigned roles
|
||
if (commandName === "verify-role") {
|
||
return res.json({
|
||
type: 4,
|
||
data: {
|
||
content: `✅ **Discord Roles**\n\nYour assigned AeThex roles are shown below.\n\n<EFBFBD><EFBFBD><EFBFBD><EFBFBD> [View Full Profile](https://aethex.dev/profile)`,
|
||
flags: 0,
|
||
},
|
||
});
|
||
}
|
||
|
||
// Default command response
|
||
return res.json({
|
||
type: 4,
|
||
data: {
|
||
content: `✨ AeThex - Advanced Development Platform\n\n**Available Commands:**\n• \`/creators [arm]\` - Browse creators across AeThex arms\n• \`/opportunities [arm]\` - Find job opportunities and collaborations\n• \`/nexus\` - Explore the Talent Marketplace\n• \`/verify\` - Link your Discord account\n• \`/set-realm\` - Choose your primary realm\n• \`/profile\` - View your AeThex profile\n• \`/unlink\` - Disconnect your account\n• \`/verify-role\` - Check your assigned Discord roles`,
|
||
flags: 0,
|
||
},
|
||
});
|
||
}
|
||
|
||
// For MESSAGE_COMPONENT interactions (buttons, etc.)
|
||
if (interaction.type === 3) {
|
||
console.log(
|
||
"[Discord] Message component interaction:",
|
||
interaction.data.custom_id,
|
||
);
|
||
|
||
return res.json({
|
||
type: 4,
|
||
data: { content: "Button clicked - feature coming soon!" },
|
||
});
|
||
}
|
||
|
||
// Acknowledge all other interactions
|
||
return res.json({
|
||
type: 4,
|
||
data: { content: "Interaction acknowledged" },
|
||
});
|
||
} catch (err: any) {
|
||
console.error("[Discord] Error:", err?.message);
|
||
return res.status(500).json({ error: "Server error" });
|
||
}
|
||
};
|
||
|
||
export function createServer() {
|
||
const app = express();
|
||
|
||
// Discord endpoint MUST be defined BEFORE any body parsing middleware
|
||
// and needs raw body for signature verification
|
||
app.post(
|
||
"/api/discord/interactions",
|
||
express.raw({ type: "application/json" }),
|
||
handleDiscordInteractions,
|
||
);
|
||
|
||
// Middleware
|
||
app.use(cors());
|
||
app.use(express.json());
|
||
app.use(express.urlencoded({ extended: true }));
|
||
|
||
// Allow Discord to embed the activity in iframes
|
||
app.use((req, res, next) => {
|
||
// Allow embedding in iframes (Discord Activities need this)
|
||
res.setHeader("X-Frame-Options", "ALLOWALL");
|
||
// Allow Discord to access the iframe
|
||
res.setHeader("Access-Control-Allow-Origin", "*");
|
||
res.setHeader(
|
||
"Access-Control-Allow-Methods",
|
||
"GET, POST, PUT, DELETE, OPTIONS",
|
||
);
|
||
res.setHeader(
|
||
"Access-Control-Allow-Headers",
|
||
"Content-Type, Authorization, x-signature-ed25519, x-signature-timestamp",
|
||
);
|
||
next();
|
||
});
|
||
|
||
// Subdomain detection middleware for aethex.me and aethex.space
|
||
app.use((req, res, next) => {
|
||
const host = (req.headers.host || "").toLowerCase();
|
||
const forwarded = (
|
||
(req.headers["x-forwarded-host"] as string) || ""
|
||
).toLowerCase();
|
||
const hostname = forwarded || host;
|
||
|
||
// Parse subdomain
|
||
let subdomain = "";
|
||
let domain = "";
|
||
|
||
if (hostname.includes("aethex.me")) {
|
||
const parts = hostname.split(".");
|
||
if (parts.length > 2) {
|
||
subdomain = parts[0];
|
||
domain = "aethex.me";
|
||
}
|
||
} else if (hostname.includes("aethex.space")) {
|
||
const parts = hostname.split(".");
|
||
if (parts.length > 2) {
|
||
subdomain = parts[0];
|
||
domain = "aethex.space";
|
||
}
|
||
}
|
||
|
||
// Attach subdomain info to request
|
||
(req as any).subdomainInfo = {
|
||
subdomain,
|
||
domain,
|
||
isCreatorPassport: domain === "aethex.me",
|
||
isProjectPassport: domain === "aethex.space",
|
||
};
|
||
|
||
console.log("[Subdomain] Detected:", {
|
||
hostname,
|
||
subdomain,
|
||
domain,
|
||
isCreatorPassport: domain === "aethex.me",
|
||
isProjectPassport: domain === "aethex.space",
|
||
});
|
||
|
||
next();
|
||
});
|
||
|
||
// Subdomain Passport Data API - returns JSON for the React component to use
|
||
app.get("/api/passport/subdomain-data/:username", async (req, res) => {
|
||
try {
|
||
const username = String(req.params.username || "")
|
||
.toLowerCase()
|
||
.trim();
|
||
|
||
if (!username) {
|
||
return res.status(400).json({ error: "username required" });
|
||
}
|
||
|
||
console.log("[Passport Data API] Fetching creator:", username);
|
||
|
||
const { data: user, error } = await adminSupabase
|
||
.from("user_profiles")
|
||
.select(
|
||
"id, username, full_name, bio, avatar_url, banner_url, location, website_url, github_url, linkedin_url, twitter_url, role, level, total_xp, user_type, experience_level, current_streak, longest_streak, created_at, updated_at",
|
||
)
|
||
.eq("username", username)
|
||
.single();
|
||
|
||
if (error || !user) {
|
||
console.log("[Passport Data API] Creator not found:", username);
|
||
return res.status(404).json({ error: "Creator not found", username });
|
||
}
|
||
|
||
// Fetch achievements
|
||
const { data: achievements = [] } = await adminSupabase
|
||
.from("user_achievements")
|
||
.select(
|
||
`
|
||
achievement_id,
|
||
achievements(
|
||
id,
|
||
name,
|
||
description,
|
||
icon,
|
||
category,
|
||
badge_color
|
||
)
|
||
`,
|
||
)
|
||
.eq("user_id", user.id);
|
||
|
||
return res.json({
|
||
type: "creator",
|
||
user,
|
||
achievements: achievements
|
||
.map((a: any) => a.achievements)
|
||
.filter(Boolean),
|
||
domain: "aethex.me",
|
||
});
|
||
} catch (e: any) {
|
||
console.error("[Passport Data API] Error:", e?.message);
|
||
return res.status(500).json({
|
||
error: e?.message || "Failed to fetch creator passport",
|
||
});
|
||
}
|
||
});
|
||
|
||
app.get("/api/passport/project-data/:projectSlug", async (req, res) => {
|
||
try {
|
||
const projectSlug = String(req.params.projectSlug || "")
|
||
.toLowerCase()
|
||
.trim();
|
||
|
||
if (!projectSlug) {
|
||
return res.status(400).json({ error: "projectSlug required" });
|
||
}
|
||
|
||
console.log("[Passport Data API] Fetching project:", projectSlug);
|
||
|
||
const { data: project, error } = await adminSupabase
|
||
.from("projects")
|
||
.select(
|
||
"id, slug, name, description, logo_url, banner_url, website_url, github_url, team_size, created_at, updated_at",
|
||
)
|
||
.eq("slug", projectSlug)
|
||
.single();
|
||
|
||
if (error || !project) {
|
||
console.log("[Passport Data API] Project not found:", projectSlug);
|
||
return res
|
||
.status(404)
|
||
.json({ error: "Project not found", projectSlug });
|
||
}
|
||
|
||
return res.json({
|
||
type: "group",
|
||
group: project,
|
||
domain: "aethex.space",
|
||
});
|
||
} catch (e: any) {
|
||
console.error("[Passport Data API] Error:", e?.message);
|
||
return res.status(500).json({
|
||
error: e?.message || "Failed to fetch project passport",
|
||
});
|
||
}
|
||
});
|
||
|
||
// Example API routes
|
||
app.get("/api/ping", (_req, res) => {
|
||
const ping = process.env.PING_MESSAGE ?? "ping";
|
||
res.json({ message: ping });
|
||
});
|
||
|
||
// API: Creator passport lookup by subdomain (aethex.me)
|
||
app.get("/api/passport/subdomain/:username", async (req, res) => {
|
||
try {
|
||
const username = String(req.params.username || "")
|
||
.toLowerCase()
|
||
.trim();
|
||
if (!username) {
|
||
return res.status(400).json({ error: "username required" });
|
||
}
|
||
|
||
const { data, error } = await adminSupabase
|
||
.from("user_profiles")
|
||
.select(
|
||
"id, username, full_name, avatar_url, user_type, bio, created_at",
|
||
)
|
||
.eq("username", username)
|
||
.single();
|
||
|
||
if (error || !data) {
|
||
return res.status(404).json({
|
||
error: "Creator not found",
|
||
username,
|
||
});
|
||
}
|
||
|
||
return res.json({
|
||
type: "creator",
|
||
user: data,
|
||
domain: "aethex.me",
|
||
});
|
||
} catch (e: any) {
|
||
console.error("[Passport Subdomain] Error:", e?.message);
|
||
return res.status(500).json({
|
||
error: e?.message || "Failed to fetch creator passport",
|
||
});
|
||
}
|
||
});
|
||
|
||
// API: Project passport lookup by subdomain (aethex.space)
|
||
app.get("/api/passport/project/:projectname", async (req, res) => {
|
||
try {
|
||
const projectname = String(req.params.projectname || "")
|
||
.toLowerCase()
|
||
.trim();
|
||
if (!projectname) {
|
||
return res.status(400).json({ error: "projectname required" });
|
||
}
|
||
|
||
// First try exact match by name
|
||
let query = adminSupabase
|
||
.from("projects")
|
||
.select(
|
||
"id, title, slug, description, user_id, created_at, updated_at, status, image_url, website",
|
||
)
|
||
.eq("slug", projectname);
|
||
|
||
let { data, error } = await query.single();
|
||
|
||
// If not found by slug, try by title (case-insensitive)
|
||
if (error && error.code === "PGRST116") {
|
||
query = adminSupabase
|
||
.from("projects")
|
||
.select(
|
||
"id, title, slug, description, user_id, created_at, updated_at, status, image_url, website",
|
||
)
|
||
.ilike("title", projectname);
|
||
|
||
const response = await query;
|
||
if (response.data && response.data.length > 0) {
|
||
data = response.data[0];
|
||
error = null;
|
||
}
|
||
}
|
||
|
||
if (error || !data) {
|
||
return res.status(404).json({
|
||
error: "Project not found",
|
||
projectname,
|
||
});
|
||
}
|
||
|
||
// Fetch project owner info
|
||
const { data: owner } = await adminSupabase
|
||
.from("user_profiles")
|
||
.select("id, username, full_name, avatar_url")
|
||
.eq("id", (data as any).user_id)
|
||
.maybeSingle();
|
||
|
||
return res.json({
|
||
type: "project",
|
||
project: data,
|
||
owner,
|
||
domain: "aethex.space",
|
||
});
|
||
} catch (e: any) {
|
||
console.error("[Project Subdomain] Error:", e?.message);
|
||
return res.status(500).json({
|
||
error: e?.message || "Failed to fetch project passport",
|
||
});
|
||
}
|
||
});
|
||
|
||
// DevConnect REST proxy (GET only)
|
||
app.get("/api/devconnect/rest/:table", async (req, res) => {
|
||
try {
|
||
const base = process.env.DEVCONNECT_URL;
|
||
const key = process.env.DEVCONNECT_ANON_KEY;
|
||
if (!base || !key)
|
||
return res.status(500).json({ error: "DevConnect env not set" });
|
||
const table = String(req.params.table || "").replace(
|
||
/[^a-zA-Z0-9_]/g,
|
||
"",
|
||
);
|
||
const qs = req.url.includes("?")
|
||
? req.url.substring(req.url.indexOf("?"))
|
||
: "";
|
||
const url = `${base}/rest/v1/${table}${qs}`;
|
||
const r = await fetch(url, {
|
||
headers: {
|
||
apikey: key,
|
||
Authorization: `Bearer ${key}`,
|
||
Accept: "application/json",
|
||
},
|
||
});
|
||
const text = await r.text();
|
||
if (!r.ok) return res.status(r.status).send(text);
|
||
res.setHeader(
|
||
"content-type",
|
||
r.headers.get("content-type") || "application/json",
|
||
);
|
||
return res.status(200).send(text);
|
||
} catch (e: any) {
|
||
return res.status(500).json({ error: e?.message || String(e) });
|
||
}
|
||
});
|
||
|
||
app.post("/api/auth/send-verification-email", async (req, res) => {
|
||
const { email, redirectTo, fullName } = (req.body || {}) as {
|
||
email?: string;
|
||
redirectTo?: string;
|
||
fullName?: string | null;
|
||
};
|
||
|
||
if (!email) {
|
||
return res.status(400).json({ error: "email is required" });
|
||
}
|
||
|
||
if (!adminSupabase?.auth?.admin) {
|
||
return res
|
||
.status(500)
|
||
.json({ error: "Supabase admin client unavailable" });
|
||
}
|
||
|
||
try {
|
||
const fallbackRedirect =
|
||
process.env.EMAIL_VERIFY_REDIRECT ??
|
||
process.env.PUBLIC_BASE_URL ??
|
||
process.env.SITE_URL ??
|
||
"https://aethex.biz/login";
|
||
|
||
const redirectUrl =
|
||
typeof redirectTo === "string" && redirectTo.startsWith("http")
|
||
? redirectTo
|
||
: fallbackRedirect;
|
||
|
||
const { data, error } = await adminSupabase.auth.admin.generateLink({
|
||
type: "signup",
|
||
email,
|
||
options: {
|
||
redirectTo: redirectUrl,
|
||
},
|
||
} as any);
|
||
|
||
if (error) {
|
||
console.error("[API] generateLink error:", {
|
||
message: error?.message || String(error),
|
||
status: (error as any)?.status || null,
|
||
code: (error as any)?.code || null,
|
||
details: (error as any)?.details || null,
|
||
});
|
||
const errMsg =
|
||
typeof error === "string"
|
||
? error
|
||
: error?.message || JSON.stringify(error);
|
||
return res
|
||
.status((error as any)?.status ?? 500)
|
||
.json({ error: errMsg });
|
||
}
|
||
|
||
const actionLink =
|
||
(data as any)?.properties?.action_link ??
|
||
(data as any)?.properties?.verification_link ??
|
||
null;
|
||
|
||
if (!actionLink) {
|
||
return res
|
||
.status(500)
|
||
.json({ error: "Failed to generate verification link" });
|
||
}
|
||
|
||
const displayName =
|
||
(data as any)?.user?.user_metadata?.full_name ?? fullName ?? null;
|
||
|
||
if (!emailService.isConfigured) {
|
||
console.warn(
|
||
"[API] Email service not configured. SMTP env vars missing:",
|
||
{
|
||
hasHost: Boolean(process.env.SMTP_HOST),
|
||
hasUser: Boolean(process.env.SMTP_USER),
|
||
hasPassword: Boolean(process.env.SMTP_PASSWORD),
|
||
},
|
||
);
|
||
return res.json({
|
||
sent: false,
|
||
verificationUrl: actionLink,
|
||
message:
|
||
"Email service not configured. Please set SMTP_HOST, SMTP_USER, and SMTP_PASSWORD to enable email sending.",
|
||
});
|
||
}
|
||
|
||
try {
|
||
await emailService.sendVerificationEmail({
|
||
to: email,
|
||
verificationUrl: actionLink,
|
||
fullName: displayName,
|
||
});
|
||
console.log("[API] Verification email sent successfully to:", email);
|
||
return res.json({ sent: true, verificationUrl: actionLink });
|
||
} catch (emailError: any) {
|
||
console.error("[API] sendVerificationEmail threw error:", {
|
||
message: emailError?.message || String(emailError),
|
||
code: emailError?.code || null,
|
||
response: emailError?.response || null,
|
||
});
|
||
// Return with manual link as fallback even if email fails
|
||
return res.status(200).json({
|
||
sent: false,
|
||
verificationUrl: actionLink,
|
||
message: `Email delivery failed: ${emailError?.message || "SMTP error"}. Use manual link to verify.`,
|
||
});
|
||
}
|
||
} catch (error: any) {
|
||
console.error("[API] send verification email failed", error);
|
||
return res
|
||
.status(500)
|
||
.json({ error: error?.message || "Unexpected error" });
|
||
}
|
||
});
|
||
|
||
// Org domain magic-link sender (Aethex)
|
||
app.post("/api/auth/send-org-link", async (req, res) => {
|
||
try {
|
||
const { email, redirectTo } = (req.body || {}) as {
|
||
email?: string;
|
||
redirectTo?: string;
|
||
};
|
||
const target = String(email || "")
|
||
.trim()
|
||
.toLowerCase();
|
||
if (!target) return res.status(400).json({ error: "email is required" });
|
||
const allowed = /@aethex\.dev$/i.test(target);
|
||
if (!allowed)
|
||
return res.status(403).json({ error: "domain not allowed" });
|
||
|
||
if (!adminSupabase?.auth?.admin) {
|
||
return res.status(500).json({ error: "Supabase admin unavailable" });
|
||
}
|
||
|
||
const fallbackRedirect =
|
||
process.env.EMAIL_VERIFY_REDIRECT ??
|
||
process.env.PUBLIC_BASE_URL ??
|
||
process.env.SITE_URL ??
|
||
"https://aethex.dev";
|
||
|
||
const toUrl =
|
||
typeof redirectTo === "string" && redirectTo.startsWith("http")
|
||
? redirectTo
|
||
: fallbackRedirect;
|
||
|
||
const { data, error } = await adminSupabase.auth.admin.generateLink({
|
||
type: "magiclink" as any,
|
||
email: target,
|
||
options: { redirectTo: toUrl },
|
||
} as any);
|
||
|
||
if (error) {
|
||
return res.status(500).json({ error: error.message || String(error) });
|
||
}
|
||
|
||
const actionLink =
|
||
(data as any)?.properties?.action_link ??
|
||
(data as any)?.properties?.verification_link ??
|
||
null;
|
||
|
||
if (!actionLink) {
|
||
return res.status(500).json({ error: "Failed to generate magic link" });
|
||
}
|
||
|
||
if (!emailService.isConfigured) {
|
||
return res.json({ sent: false, verificationUrl: actionLink });
|
||
}
|
||
|
||
await emailService.sendVerificationEmail({
|
||
to: target,
|
||
verificationUrl: actionLink,
|
||
fullName: null,
|
||
});
|
||
|
||
return res.json({ sent: true });
|
||
} catch (e: any) {
|
||
return res.status(500).json({ error: e?.message || String(e) });
|
||
}
|
||
});
|
||
|
||
app.post("/api/auth/check-verification", async (req, res) => {
|
||
const { email } = (req.body || {}) as { email?: string };
|
||
|
||
if (!email) {
|
||
return res.status(400).json({ error: "email is required" });
|
||
}
|
||
|
||
if (!adminSupabase) {
|
||
return res
|
||
.status(500)
|
||
.json({ error: "Supabase admin client unavailable" });
|
||
}
|
||
|
||
try {
|
||
const targetEmail = String(email).trim().toLowerCase();
|
||
|
||
// Prefer GoTrue Admin listUsers; fall back to pagination if email filter unsupported
|
||
const admin = (adminSupabase as any)?.auth?.admin;
|
||
if (!admin) {
|
||
return res.status(500).json({ error: "Auth admin unavailable" });
|
||
}
|
||
|
||
let user: any = null;
|
||
let listResp: any = null;
|
||
try {
|
||
listResp = await admin.listUsers({
|
||
page: 1,
|
||
perPage: 200,
|
||
email: targetEmail,
|
||
} as any);
|
||
} catch (e) {
|
||
listResp = null;
|
||
}
|
||
|
||
const initialUsers: any[] = (listResp?.data?.users as any[]) || [];
|
||
user =
|
||
initialUsers.find(
|
||
(u: any) => String(u?.email || "").toLowerCase() === targetEmail,
|
||
) || null;
|
||
|
||
if (!user) {
|
||
// Pagination fallback (limited scan)
|
||
for (let page = 1; page <= 5 && !user; page++) {
|
||
const resp = await admin
|
||
.listUsers({ page, perPage: 200 } as any)
|
||
.catch(() => null);
|
||
const users = (resp?.data?.users as any[]) || [];
|
||
user =
|
||
users.find(
|
||
(u: any) => String(u?.email || "").toLowerCase() === targetEmail,
|
||
) || null;
|
||
}
|
||
}
|
||
|
||
if (!user) {
|
||
return res.json({ verified: false, user: null, reason: "not_found" });
|
||
}
|
||
|
||
const verified = Boolean(user?.email_confirmed_at || user?.confirmed_at);
|
||
|
||
if (verified) {
|
||
try {
|
||
const { data: ach, error: aErr } = await adminSupabase
|
||
.from("achievements")
|
||
.select("id")
|
||
.eq("name", "Founding Member")
|
||
.maybeSingle();
|
||
|
||
if (!aErr && ach?.id) {
|
||
const { error: uaErr } = await adminSupabase
|
||
.from("user_achievements")
|
||
.upsert(
|
||
{ user_id: user.id, achievement_id: ach.id },
|
||
{ onConflict: "user_id,achievement_id" as any },
|
||
);
|
||
if (uaErr) {
|
||
console.warn("Failed to award Founding Member:", uaErr);
|
||
}
|
||
}
|
||
} catch (awardErr) {
|
||
console.warn("Awarding achievement on verification failed", awardErr);
|
||
}
|
||
}
|
||
|
||
return res.json({ verified, user });
|
||
} catch (e: any) {
|
||
console.error("[API] check verification exception", e);
|
||
return res.status(500).json({ error: e?.message || String(e) });
|
||
}
|
||
});
|
||
|
||
// Storage administration endpoints (service role)
|
||
app.post("/api/storage/ensure-buckets", async (_req, res) => {
|
||
if (!adminSupabase) {
|
||
return res
|
||
.status(500)
|
||
.json({ error: "Supabase admin client unavailable" });
|
||
}
|
||
try {
|
||
const targets = [
|
||
{ name: "avatars", public: true },
|
||
{ name: "banners", public: true },
|
||
{ name: "post_media", public: true },
|
||
];
|
||
const { data: buckets } = await (
|
||
adminSupabase as any
|
||
).storage.listBuckets();
|
||
const existing = new Set((buckets || []).map((b: any) => b.name));
|
||
const created: string[] = [];
|
||
for (const t of targets) {
|
||
if (!existing.has(t.name)) {
|
||
const { error } = await (adminSupabase as any).storage.createBucket(
|
||
t.name,
|
||
{ public: t.public },
|
||
);
|
||
if (error) {
|
||
console.warn("Failed to create bucket", t.name, error);
|
||
} else {
|
||
created.push(t.name);
|
||
}
|
||
}
|
||
}
|
||
return res.json({ ok: true, created });
|
||
} catch (e: any) {
|
||
return res.status(500).json({ error: e?.message || String(e) });
|
||
}
|
||
});
|
||
|
||
// Admin-backed API (service role)
|
||
try {
|
||
const ownerEmail = (
|
||
process.env.AETHEX_OWNER_EMAIL || "mrpiglr@gmail.com"
|
||
).toLowerCase();
|
||
const isTableMissing = (err: any) => {
|
||
const code = err?.code;
|
||
const message = String(err?.message || err?.hint || err?.details || "");
|
||
return (
|
||
code === "42P01" ||
|
||
message.includes("relation") ||
|
||
message.includes("does not exist")
|
||
);
|
||
};
|
||
|
||
// Roblox OAuth: start (build authorize URL with PKCE and redirect)
|
||
app.get("/api/roblox/oauth/start", (req, res) => {
|
||
try {
|
||
const clientId = process.env.ROBLOX_OAUTH_CLIENT_ID;
|
||
if (!clientId)
|
||
return res.status(500).json({ error: "Roblox OAuth not configured" });
|
||
|
||
const baseSite =
|
||
process.env.PUBLIC_BASE_URL ||
|
||
process.env.SITE_URL ||
|
||
"https://aethex.dev";
|
||
const redirectUri =
|
||
typeof req.query.redirect_uri === "string" &&
|
||
req.query.redirect_uri.startsWith("http")
|
||
? String(req.query.redirect_uri)
|
||
: process.env.ROBLOX_OAUTH_REDIRECT_URI ||
|
||
`${baseSite}/roblox-callback`;
|
||
|
||
const scope = String(
|
||
req.query.scope || process.env.ROBLOX_OAUTH_SCOPE || "openid",
|
||
);
|
||
const state = String(req.query.state || randomUUID());
|
||
|
||
// PKCE
|
||
const codeVerifier = Buffer.from(randomUUID() + randomUUID())
|
||
.toString("base64url")
|
||
.slice(0, 64);
|
||
const codeChallenge = createHash("sha256")
|
||
.update(codeVerifier)
|
||
.digest("base64")
|
||
.replace(/\+/g, "-")
|
||
.replace(/\//g, "_")
|
||
.replace(/=+$/g, "");
|
||
|
||
const params = new URLSearchParams({
|
||
client_id: clientId,
|
||
response_type: "code",
|
||
redirect_uri: redirectUri,
|
||
scope,
|
||
state,
|
||
code_challenge: codeChallenge,
|
||
code_challenge_method: "S256",
|
||
});
|
||
const authorizeUrl = `https://apis.roblox.com/oauth/authorize?${params.toString()}`;
|
||
|
||
// set short-lived cookies for verifier/state (for callback validation)
|
||
const secure =
|
||
req.secure ||
|
||
req.get("x-forwarded-proto") === "https" ||
|
||
process.env.NODE_ENV === "production";
|
||
res.cookie("roblox_oauth_state", state, {
|
||
httpOnly: true,
|
||
sameSite: "lax",
|
||
secure,
|
||
maxAge: 10 * 60 * 1000,
|
||
path: "/",
|
||
});
|
||
res.cookie("roblox_oauth_code_verifier", codeVerifier, {
|
||
httpOnly: true,
|
||
sameSite: "lax",
|
||
secure,
|
||
maxAge: 10 * 60 * 1000,
|
||
path: "/",
|
||
});
|
||
|
||
if (String(req.query.json || "").toLowerCase() === "true") {
|
||
return res.json({ authorizeUrl, state });
|
||
}
|
||
return res.redirect(302, authorizeUrl);
|
||
} catch (e: any) {
|
||
return res.status(500).json({ error: e?.message || String(e) });
|
||
}
|
||
});
|
||
|
||
// Discord OAuth: start authorization flow
|
||
app.get("/api/discord/oauth/start", (req, res) => {
|
||
try {
|
||
const clientId = process.env.DISCORD_CLIENT_ID;
|
||
if (!clientId) {
|
||
return res
|
||
.status(500)
|
||
.json({ error: "Discord client ID not configured" });
|
||
}
|
||
|
||
// Use the API base URL (should match Discord Dev Portal redirect URI)
|
||
let apiBase =
|
||
process.env.VITE_API_BASE ||
|
||
process.env.API_BASE ||
|
||
process.env.PUBLIC_BASE_URL ||
|
||
process.env.SITE_URL;
|
||
|
||
if (!apiBase) {
|
||
// Fallback to request origin if no env var is set
|
||
const protocol =
|
||
req.headers["x-forwarded-proto"] || req.protocol || "https";
|
||
const host =
|
||
req.headers["x-forwarded-host"] || req.hostname || req.get("host");
|
||
apiBase = `${protocol}://${host}`;
|
||
}
|
||
|
||
const redirectUri = `${apiBase}/api/discord/oauth/callback`;
|
||
console.log(
|
||
"[Discord OAuth Start] Using redirect URI:",
|
||
redirectUri,
|
||
"from API base:",
|
||
apiBase,
|
||
);
|
||
|
||
// Get the state from query params (can be a JSON string with action and redirectTo)
|
||
let state = req.query.state || "/dashboard";
|
||
if (typeof state !== "string") {
|
||
state = "/dashboard";
|
||
}
|
||
|
||
const params = new URLSearchParams({
|
||
client_id: clientId,
|
||
redirect_uri: redirectUri,
|
||
response_type: "code",
|
||
scope: "identify email",
|
||
state: state,
|
||
});
|
||
|
||
const discordOAuthUrl = `https://discord.com/api/oauth2/authorize?${params.toString()}`;
|
||
console.log("[Discord OAuth Start] Redirecting to:", discordOAuthUrl);
|
||
return res.redirect(302, discordOAuthUrl);
|
||
} catch (e: any) {
|
||
return res.status(500).json({ error: e?.message || String(e) });
|
||
}
|
||
});
|
||
|
||
// Discord OAuth: callback handler
|
||
app.get("/api/discord/oauth/callback", async (req, res) => {
|
||
const code = req.query.code as string;
|
||
const state = req.query.state as string;
|
||
const error = req.query.error as string;
|
||
|
||
if (error) {
|
||
return res.redirect(`/login?error=${error}`);
|
||
}
|
||
|
||
if (!code) {
|
||
return res.redirect("/login?error=no_code");
|
||
}
|
||
|
||
// Parse state to determine if this is a linking or login flow
|
||
let isLinkingFlow = false;
|
||
let redirectTo = "/dashboard";
|
||
let authenticatedUserId: string | null = null;
|
||
|
||
if (state) {
|
||
try {
|
||
const stateData = JSON.parse(decodeURIComponent(state));
|
||
isLinkingFlow = stateData.action === "link";
|
||
redirectTo = stateData.redirectTo || redirectTo;
|
||
|
||
if (isLinkingFlow && stateData.sessionToken) {
|
||
// Look up the linking session to get the user ID
|
||
const { data: session, error: sessionError } = await adminSupabase
|
||
.from("discord_linking_sessions")
|
||
.select("user_id")
|
||
.eq("session_token", stateData.sessionToken)
|
||
.gt("expires_at", new Date().toISOString())
|
||
.single();
|
||
|
||
if (sessionError || !session) {
|
||
console.error(
|
||
"[Discord OAuth] Linking session not found or expired",
|
||
);
|
||
return res.redirect(
|
||
"/login?error=session_lost&message=Session%20expired.%20Please%20try%20linking%20Discord%20again.",
|
||
);
|
||
}
|
||
|
||
authenticatedUserId = session.user_id;
|
||
console.log(
|
||
"[Discord OAuth] Linking session found, user_id:",
|
||
authenticatedUserId,
|
||
);
|
||
|
||
// Clean up the temporary session
|
||
await adminSupabase
|
||
.from("discord_linking_sessions")
|
||
.delete()
|
||
.eq("session_token", stateData.sessionToken);
|
||
}
|
||
} catch (e) {
|
||
console.log("[Discord OAuth] Could not parse state:", e);
|
||
}
|
||
}
|
||
|
||
try {
|
||
const clientId = process.env.DISCORD_CLIENT_ID;
|
||
const clientSecret = process.env.DISCORD_CLIENT_SECRET;
|
||
|
||
// Use the same redirect URI as the start endpoint
|
||
let apiBase =
|
||
process.env.VITE_API_BASE ||
|
||
process.env.API_BASE ||
|
||
process.env.PUBLIC_BASE_URL ||
|
||
process.env.SITE_URL;
|
||
|
||
if (!apiBase) {
|
||
// Fallback to request origin if no env var is set
|
||
const protocol =
|
||
req.headers["x-forwarded-proto"] || req.protocol || "https";
|
||
const host =
|
||
req.headers["x-forwarded-host"] || req.hostname || req.get("host");
|
||
apiBase = `${protocol}://${host}`;
|
||
}
|
||
|
||
const redirectUri = `${apiBase}/api/discord/oauth/callback`;
|
||
console.log(
|
||
"[Discord OAuth Callback] Received callback, redirect URI:",
|
||
redirectUri,
|
||
);
|
||
|
||
if (!clientId || !clientSecret) {
|
||
console.error("[Discord OAuth] Missing client credentials");
|
||
return res.redirect("/login?error=config");
|
||
}
|
||
|
||
// Exchange authorization code for access token
|
||
const tokenResponse = await fetch(
|
||
"https://discord.com/api/oauth2/token",
|
||
{
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/x-www-form-urlencoded",
|
||
},
|
||
body: new URLSearchParams({
|
||
client_id: clientId,
|
||
client_secret: clientSecret,
|
||
code,
|
||
grant_type: "authorization_code",
|
||
redirect_uri: redirectUri,
|
||
}).toString(),
|
||
},
|
||
);
|
||
|
||
if (!tokenResponse.ok) {
|
||
const errorData = await tokenResponse.json();
|
||
console.error("[Discord OAuth] Token exchange failed:", errorData);
|
||
return res.redirect("/login?error=token_exchange");
|
||
}
|
||
|
||
const tokenData = await tokenResponse.json();
|
||
|
||
// Get Discord user information
|
||
const userResponse = await fetch(
|
||
"https://discord.com/api/v10/users/@me",
|
||
{
|
||
headers: {
|
||
Authorization: `Bearer ${tokenData.access_token}`,
|
||
},
|
||
},
|
||
);
|
||
|
||
if (!userResponse.ok) {
|
||
console.error(
|
||
"[Discord OAuth] User fetch failed:",
|
||
userResponse.status,
|
||
);
|
||
return res.redirect("/login?error=user_fetch");
|
||
}
|
||
|
||
const discordUser = await userResponse.json();
|
||
|
||
if (!discordUser.email) {
|
||
console.error("[Discord OAuth] Discord user has no email");
|
||
return res.redirect(
|
||
"/login?error=no_email&message=Please+enable+email+on+your+Discord+account",
|
||
);
|
||
}
|
||
|
||
// LINKING FLOW: Link Discord to authenticated user
|
||
if (isLinkingFlow && authenticatedUserId) {
|
||
console.log(
|
||
"[Discord OAuth] Linking Discord to user:",
|
||
authenticatedUserId,
|
||
);
|
||
|
||
// Check if Discord ID is already linked to someone else
|
||
const { data: existingLink } = await adminSupabase
|
||
.from("discord_links")
|
||
.select("user_id")
|
||
.eq("discord_id", discordUser.id)
|
||
.single();
|
||
|
||
if (existingLink && existingLink.user_id !== authenticatedUserId) {
|
||
console.error(
|
||
"[Discord OAuth] Discord ID already linked to different user",
|
||
);
|
||
return res.redirect(
|
||
`/dashboard?error=already_linked&message=${encodeURIComponent("This Discord account is already linked to another AeThex account")}`,
|
||
);
|
||
}
|
||
|
||
// Create or update Discord link
|
||
const { error: linkError } = await adminSupabase
|
||
.from("discord_links")
|
||
.upsert({
|
||
discord_id: discordUser.id,
|
||
user_id: authenticatedUserId,
|
||
linked_at: new Date().toISOString(),
|
||
});
|
||
|
||
if (linkError) {
|
||
console.error("[Discord OAuth] Link creation failed:", linkError);
|
||
return res.redirect(
|
||
`/dashboard?error=link_failed&message=${encodeURIComponent("Failed to link Discord account")}`,
|
||
);
|
||
}
|
||
|
||
console.log(
|
||
"[Discord OAuth] Successfully linked Discord:",
|
||
discordUser.id,
|
||
);
|
||
return res.redirect(redirectTo);
|
||
}
|
||
|
||
// LOGIN FLOW: Check if Discord user already exists
|
||
const { data: existingLink } = await adminSupabase
|
||
.from("discord_links")
|
||
.select("user_id")
|
||
.eq("discord_id", discordUser.id)
|
||
.single();
|
||
|
||
let userId: string;
|
||
|
||
if (existingLink) {
|
||
// Discord ID already linked - use existing user
|
||
userId = existingLink.user_id;
|
||
console.log(
|
||
"[Discord OAuth] Discord ID already linked to user:",
|
||
userId,
|
||
);
|
||
} else {
|
||
// Check if email matches existing account
|
||
const { data: existingUserProfile } = await adminSupabase
|
||
.from("user_profiles")
|
||
.select("id")
|
||
.eq("email", discordUser.email)
|
||
.single();
|
||
|
||
if (existingUserProfile) {
|
||
// Discord email matches existing user profile - link it
|
||
userId = existingUserProfile.id;
|
||
console.log(
|
||
"[Discord OAuth] Discord email matches existing user profile, linking Discord",
|
||
);
|
||
} else {
|
||
// Discord email doesn't match any existing account
|
||
// Don't auto-create - ask user to sign in with email first
|
||
console.log(
|
||
"[Discord OAuth] Discord email not found in existing accounts, redirecting to sign in",
|
||
);
|
||
return res.redirect(
|
||
`/login?error=discord_no_match&message=${encodeURIComponent(`Discord email (${discordUser.email}) not found. Please sign in with your email account first, then link Discord from settings.`)}`,
|
||
);
|
||
}
|
||
}
|
||
|
||
// Create Discord link
|
||
const { error: linkError } = await adminSupabase
|
||
.from("discord_links")
|
||
.upsert({
|
||
discord_id: discordUser.id,
|
||
user_id: userId,
|
||
linked_at: new Date().toISOString(),
|
||
});
|
||
|
||
if (linkError) {
|
||
console.error("[Discord OAuth] Link creation failed:", linkError);
|
||
return res.redirect("/login?error=link_create");
|
||
}
|
||
|
||
// Discord is now linked! Redirect to login for user to sign in
|
||
console.log(
|
||
"[Discord OAuth] Discord linked successfully, redirecting to login",
|
||
);
|
||
return res.redirect(
|
||
`/login?discord_linked=true&email=${encodeURIComponent(discordUser.email)}`,
|
||
);
|
||
} catch (e: any) {
|
||
console.error("[Discord OAuth] Callback error:", e);
|
||
return res.redirect("/login?error=unknown");
|
||
}
|
||
});
|
||
|
||
// Discord Create Linking Session: Creates temporary session for OAuth linking
|
||
app.post("/api/discord/create-linking-session", async (req, res) => {
|
||
try {
|
||
const authHeader = req.headers.authorization;
|
||
if (!authHeader) {
|
||
return res.status(401).json({ error: "Not authenticated" });
|
||
}
|
||
|
||
const token = authHeader.replace("Bearer ", "");
|
||
const {
|
||
data: { user },
|
||
error,
|
||
} = await adminSupabase.auth.getUser(token);
|
||
|
||
if (error || !user) {
|
||
return res.status(401).json({ error: "Invalid auth token" });
|
||
}
|
||
|
||
const sessionToken = randomBytes(32).toString("hex");
|
||
const expiresAt = new Date(Date.now() + 5 * 60 * 1000).toISOString();
|
||
|
||
const { error: insertError } = await adminSupabase
|
||
.from("discord_linking_sessions")
|
||
.insert({
|
||
user_id: user.id,
|
||
session_token: sessionToken,
|
||
expires_at: expiresAt,
|
||
});
|
||
|
||
if (insertError) {
|
||
console.error("[Discord] Session insert error:", {
|
||
code: insertError.code,
|
||
message: insertError.message,
|
||
details: insertError.details,
|
||
hint: insertError.hint,
|
||
});
|
||
return res.status(500).json({
|
||
error: insertError.message,
|
||
details: insertError.details,
|
||
});
|
||
}
|
||
|
||
res.json({ token: sessionToken });
|
||
} catch (error: any) {
|
||
console.error("[Discord] Create session error:", error);
|
||
res.status(500).json({ error: error.message });
|
||
}
|
||
});
|
||
|
||
// Discord Verify Code: Link account using verification code
|
||
app.post("/api/discord/verify-code", async (req, res) => {
|
||
const { verification_code, user_id } = req.body || {};
|
||
|
||
console.log("[Discord Verify] Request received:", {
|
||
verification_code: verification_code ? "***" : "missing",
|
||
user_id: user_id || "missing",
|
||
});
|
||
|
||
if (!verification_code || !user_id) {
|
||
console.error("[Discord Verify] Missing params:", {
|
||
hasCode: !!verification_code,
|
||
hasUserId: !!user_id,
|
||
});
|
||
return res.status(400).json({
|
||
message: "Missing verification code or user ID",
|
||
});
|
||
}
|
||
|
||
try {
|
||
// Find valid verification code
|
||
const { data: verification, error: verifyError } = await adminSupabase
|
||
.from("discord_verifications")
|
||
.select("*")
|
||
.eq("verification_code", verification_code.trim())
|
||
.gt("expires_at", new Date().toISOString())
|
||
.single();
|
||
|
||
if (verifyError) {
|
||
console.error(
|
||
"[Discord Verify] Error querying verification code:",
|
||
verifyError,
|
||
);
|
||
return res.status(400).json({
|
||
message:
|
||
"Invalid or expired verification code. Please try /verify again.",
|
||
});
|
||
}
|
||
|
||
if (!verification) {
|
||
console.warn(
|
||
"[Discord Verify] Verification code not found or expired:",
|
||
verification_code,
|
||
);
|
||
return res.status(400).json({
|
||
message:
|
||
"Invalid or expired verification code. Please try /verify again.",
|
||
});
|
||
}
|
||
|
||
const discordId = verification.discord_id;
|
||
console.log(
|
||
"[Discord Verify] Found verification code for Discord ID:",
|
||
{
|
||
discordId,
|
||
userId: user_id,
|
||
},
|
||
);
|
||
|
||
// Check if already linked
|
||
const { data: existingLink, error: linkCheckError } =
|
||
await adminSupabase
|
||
.from("discord_links")
|
||
.select("*")
|
||
.eq("discord_id", discordId)
|
||
.single();
|
||
|
||
if (linkCheckError && linkCheckError.code !== "PGRST116") {
|
||
console.error(
|
||
"[Discord Verify] Error checking existing link:",
|
||
linkCheckError,
|
||
);
|
||
}
|
||
|
||
if (existingLink && existingLink.user_id !== user_id) {
|
||
console.warn(
|
||
"[Discord Verify] Discord ID already linked to different user:",
|
||
{
|
||
discordId,
|
||
existingUserId: existingLink.user_id,
|
||
newUserId: user_id,
|
||
},
|
||
);
|
||
return res.status(400).json({
|
||
message:
|
||
"This Discord account is already linked to another AeThex account.",
|
||
});
|
||
}
|
||
|
||
// Create or update link
|
||
const { error: linkError } = await adminSupabase
|
||
.from("discord_links")
|
||
.upsert({
|
||
discord_id: discordId,
|
||
user_id: user_id,
|
||
linked_at: new Date().toISOString(),
|
||
});
|
||
|
||
if (linkError) {
|
||
console.error("[Discord Verify] Link creation failed:", linkError);
|
||
return res.status(500).json({
|
||
message: "Failed to link Discord account: " + linkError.message,
|
||
});
|
||
}
|
||
|
||
console.log("[Discord Verify] Link created successfully:", {
|
||
discordId,
|
||
userId: user_id,
|
||
});
|
||
|
||
// Delete used verification code
|
||
await adminSupabase
|
||
.from("discord_verifications")
|
||
.delete()
|
||
.eq("verification_code", verification_code.trim());
|
||
|
||
res.status(200).json({
|
||
success: true,
|
||
message: "Discord account linked successfully!",
|
||
discord_user: {
|
||
id: discordId,
|
||
username: verification.username || "Discord User",
|
||
},
|
||
});
|
||
} catch (error: any) {
|
||
console.error("[Discord Verify] Unexpected error:", error);
|
||
res.status(500).json({
|
||
message: "An error occurred. Please try again: " + error?.message,
|
||
});
|
||
}
|
||
});
|
||
|
||
// Discord Role Mappings CRUD
|
||
app.get("/api/discord/role-mappings", async (req, res) => {
|
||
try {
|
||
const { data, error } = await adminSupabase
|
||
.from("discord_role_mappings")
|
||
.select("*")
|
||
.order("created_at", { ascending: false });
|
||
|
||
if (error) {
|
||
console.error("[Discord] Error fetching role mappings:", error);
|
||
return res.status(500).json({
|
||
error: `Failed to fetch role mappings: ${error.message}`,
|
||
});
|
||
}
|
||
|
||
res.setHeader("Content-Type", "application/json");
|
||
return res.json(data || []);
|
||
} catch (e: any) {
|
||
console.error("[Discord] Exception fetching role mappings:", e);
|
||
res.setHeader("Content-Type", "application/json");
|
||
return res.status(500).json({
|
||
error: e?.message || "Failed to fetch role mappings",
|
||
});
|
||
}
|
||
});
|
||
|
||
// Discord Activity Token Exchange Endpoint
|
||
app.post("/api/discord/token", async (req, res) => {
|
||
try {
|
||
const { code } = req.body;
|
||
|
||
if (!code) {
|
||
return res.status(400).json({ error: "Missing authorization code" });
|
||
}
|
||
|
||
const clientId = process.env.DISCORD_CLIENT_ID;
|
||
const clientSecret = process.env.DISCORD_CLIENT_SECRET;
|
||
|
||
if (!clientId || !clientSecret) {
|
||
console.error("[Discord Token] Missing CLIENT_ID or CLIENT_SECRET");
|
||
return res.status(500).json({ error: "Server not configured" });
|
||
}
|
||
|
||
// Exchange authorization code for access token
|
||
const tokenResponse = await fetch(
|
||
"https://discord.com/api/v10/oauth2/token",
|
||
{
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/x-www-form-urlencoded",
|
||
},
|
||
body: new URLSearchParams({
|
||
client_id: clientId,
|
||
client_secret: clientSecret,
|
||
grant_type: "authorization_code",
|
||
code,
|
||
redirect_uri:
|
||
process.env.DISCORD_ACTIVITY_REDIRECT_URI ||
|
||
"https://aethex.dev/activity",
|
||
}).toString(),
|
||
},
|
||
);
|
||
|
||
if (!tokenResponse.ok) {
|
||
const error = await tokenResponse.json();
|
||
console.error("[Discord Token] Token exchange failed:", error);
|
||
return res.status(400).json({
|
||
error: "Failed to exchange code for token",
|
||
details: error,
|
||
});
|
||
}
|
||
|
||
const tokenData = await tokenResponse.json();
|
||
const accessToken = tokenData.access_token;
|
||
|
||
if (!accessToken) {
|
||
console.error("[Discord Token] No access token in response");
|
||
return res
|
||
.status(500)
|
||
.json({ error: "Failed to obtain access token" });
|
||
}
|
||
|
||
// Fetch Discord user info to ensure token is valid
|
||
const userResponse = await fetch(
|
||
"https://discord.com/api/v10/users/@me",
|
||
{
|
||
headers: {
|
||
Authorization: `Bearer ${accessToken}`,
|
||
},
|
||
},
|
||
);
|
||
|
||
if (!userResponse.ok) {
|
||
console.error("[Discord Token] Failed to fetch user info");
|
||
return res.status(401).json({ error: "Invalid token" });
|
||
}
|
||
|
||
const discordUser = await userResponse.json();
|
||
console.log(
|
||
"[Discord Token] Token exchange successful for user:",
|
||
discordUser.id,
|
||
);
|
||
|
||
// Return access token to Activity
|
||
return res.status(200).json({
|
||
access_token: accessToken,
|
||
token_type: tokenData.token_type,
|
||
expires_in: tokenData.expires_in,
|
||
user_id: discordUser.id,
|
||
username: discordUser.username,
|
||
});
|
||
} catch (error) {
|
||
console.error("[Discord Token] Error:", error);
|
||
return res.status(500).json({
|
||
error: "Internal server error",
|
||
message: error instanceof Error ? error.message : String(error),
|
||
});
|
||
}
|
||
});
|
||
|
||
app.post("/api/discord/role-mappings", async (req, res) => {
|
||
try {
|
||
const { arm, discord_role, discord_role_name, server_id, user_type } =
|
||
req.body;
|
||
|
||
const roleName = discord_role_name || discord_role;
|
||
if (!arm || !roleName) {
|
||
return res.status(400).json({
|
||
error: "arm and discord_role (or discord_role_name) are required",
|
||
});
|
||
}
|
||
|
||
const { data, error } = await adminSupabase
|
||
.from("discord_role_mappings")
|
||
.insert({
|
||
arm,
|
||
user_type: user_type || "community_member",
|
||
discord_role_name: roleName,
|
||
server_id: server_id || null,
|
||
})
|
||
.select()
|
||
.single();
|
||
|
||
if (error) {
|
||
console.error("[Discord] Error creating role mapping:", error);
|
||
return res.status(500).json({
|
||
error: `Failed to create mapping: ${error.message}`,
|
||
});
|
||
}
|
||
|
||
res.setHeader("Content-Type", "application/json");
|
||
return res.status(201).json(data);
|
||
} catch (e: any) {
|
||
console.error("[Discord] Exception creating role mapping:", e);
|
||
res.setHeader("Content-Type", "application/json");
|
||
return res.status(500).json({
|
||
error: e?.message || "Failed to create mapping",
|
||
});
|
||
}
|
||
});
|
||
|
||
app.put("/api/discord/role-mappings", async (req, res) => {
|
||
try {
|
||
const {
|
||
id,
|
||
arm,
|
||
discord_role,
|
||
discord_role_name,
|
||
server_id,
|
||
user_type,
|
||
} = req.body;
|
||
|
||
if (!id) {
|
||
return res.status(400).json({ error: "id is required" });
|
||
}
|
||
|
||
const updateData: any = {};
|
||
if (arm) updateData.arm = arm;
|
||
const roleName = discord_role_name || discord_role;
|
||
if (roleName) updateData.discord_role_name = roleName;
|
||
if (server_id !== undefined) updateData.server_id = server_id;
|
||
if (user_type) updateData.user_type = user_type;
|
||
|
||
const { data, error } = await adminSupabase
|
||
.from("discord_role_mappings")
|
||
.update(updateData)
|
||
.eq("id", id)
|
||
.select()
|
||
.single();
|
||
|
||
if (error) {
|
||
console.error("[Discord] Error updating role mapping:", error);
|
||
return res.status(500).json({
|
||
error: `Failed to update mapping: ${error.message}`,
|
||
});
|
||
}
|
||
|
||
res.setHeader("Content-Type", "application/json");
|
||
return res.json(data);
|
||
} catch (e: any) {
|
||
console.error("[Discord] Exception updating role mapping:", e);
|
||
res.setHeader("Content-Type", "application/json");
|
||
return res.status(500).json({
|
||
error: e?.message || "Failed to update mapping",
|
||
});
|
||
}
|
||
});
|
||
|
||
app.delete("/api/discord/role-mappings", async (req, res) => {
|
||
try {
|
||
const { id } = req.query;
|
||
|
||
if (!id) {
|
||
return res.status(400).json({
|
||
error: "id query parameter is required",
|
||
});
|
||
}
|
||
|
||
const { error } = await adminSupabase
|
||
.from("discord_role_mappings")
|
||
.delete()
|
||
.eq("id", id);
|
||
|
||
if (error) {
|
||
console.error("[Discord] Error deleting role mapping:", error);
|
||
return res.status(500).json({
|
||
error: `Failed to delete mapping: ${error.message}`,
|
||
});
|
||
}
|
||
|
||
res.setHeader("Content-Type", "application/json");
|
||
return res.json({ success: true });
|
||
} catch (e: any) {
|
||
console.error("[Discord] Exception deleting role mapping:", e);
|
||
res.setHeader("Content-Type", "application/json");
|
||
return res.status(500).json({
|
||
error: e?.message || "Failed to delete mapping",
|
||
});
|
||
}
|
||
});
|
||
|
||
// Discord Admin Register Commands
|
||
app.get("/api/discord/admin-register-commands", async (req, res) => {
|
||
// GET: Show HTML form for browser access
|
||
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
||
return res.send(`
|
||
<!DOCTYPE html>
|
||
<html>
|
||
<head>
|
||
<title>Register Discord Commands</title>
|
||
<style>
|
||
body {
|
||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||
display: flex;
|
||
justify-content: center;
|
||
align-items: center;
|
||
min-height: 100vh;
|
||
margin: 0;
|
||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||
}
|
||
.container {
|
||
background: white;
|
||
padding: 40px;
|
||
border-radius: 12px;
|
||
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
|
||
text-align: center;
|
||
max-width: 500px;
|
||
width: 90%;
|
||
}
|
||
h1 { color: #333; margin-bottom: 10px; font-size: 28px; }
|
||
p { color: #666; margin-bottom: 30px; line-height: 1.6; }
|
||
.input-group {
|
||
margin-bottom: 20px;
|
||
text-align: left;
|
||
}
|
||
label {
|
||
display: block;
|
||
color: #333;
|
||
font-weight: bold;
|
||
margin-bottom: 8px;
|
||
font-size: 14px;
|
||
}
|
||
input {
|
||
width: 100%;
|
||
padding: 12px;
|
||
border: 2px solid #ddd;
|
||
border-radius: 6px;
|
||
font-size: 14px;
|
||
box-sizing: border-box;
|
||
transition: border-color 0.3s;
|
||
}
|
||
input:focus {
|
||
outline: none;
|
||
border-color: #667eea;
|
||
}
|
||
button {
|
||
background: #667eea;
|
||
color: white;
|
||
border: none;
|
||
padding: 14px 40px;
|
||
border-radius: 6px;
|
||
cursor: pointer;
|
||
font-size: 16px;
|
||
font-weight: bold;
|
||
transition: all 0.3s;
|
||
width: 100%;
|
||
}
|
||
button:hover {
|
||
background: #764ba2;
|
||
transform: translateY(-2px);
|
||
box-shadow: 0 5px 15px rgba(102, 126, 234, 0.4);
|
||
}
|
||
button:disabled {
|
||
background: #ccc;
|
||
cursor: not-allowed;
|
||
transform: none;
|
||
}
|
||
#result {
|
||
margin-top: 30px;
|
||
padding: 20px;
|
||
border-radius: 6px;
|
||
display: none;
|
||
font-size: 14px;
|
||
word-break: break-all;
|
||
}
|
||
#result.success {
|
||
background: #d4edda;
|
||
color: #155724;
|
||
border: 1px solid #c3e6cb;
|
||
display: block;
|
||
}
|
||
#result.error {
|
||
background: #f8d7da;
|
||
color: #721c24;
|
||
border: 1px solid #f5c6cb;
|
||
display: block;
|
||
}
|
||
#loading {
|
||
display: none;
|
||
color: #667eea;
|
||
font-weight: bold;
|
||
margin-top: 20px;
|
||
}
|
||
.commands-list {
|
||
background: #f0f0f0;
|
||
padding: 15px;
|
||
border-radius: 6px;
|
||
margin: 20px 0;
|
||
font-size: 13px;
|
||
text-align: left;
|
||
color: #333;
|
||
}
|
||
.commands-list li { margin: 5px 0; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="container">
|
||
<h1><3E><><EFBFBD> Discord Commands Registration</h1>
|
||
<p>Register all Discord slash commands for AeThex</p>
|
||
|
||
<div class="commands-list">
|
||
<strong>Commands to be registered:</strong>
|
||
<ul>
|
||
<li>✅ /verify - Link your Discord account</li>
|
||
<li>✅ /set-realm - Choose your primary arm</li>
|
||
<li>✅ /profile - View your AeThex profile</li>
|
||
<li>✅ /unlink - Disconnect your account</li>
|
||
<li>✅ /verify-role - Check your Discord roles</li>
|
||
</ul>
|
||
</div>
|
||
|
||
<div class="input-group">
|
||
<label for="token">Admin Token:</label>
|
||
<input type="password" id="token" placeholder="Enter admin token" value="aethex-link" />
|
||
</div>
|
||
|
||
<button id="registerBtn" onclick="registerCommands()">🚀 Register Commands Now</button>
|
||
|
||
<div id="loading">⏳ Registering commands... please wait...</div>
|
||
<div id="result"></div>
|
||
</div>
|
||
|
||
<script>
|
||
async function registerCommands() {
|
||
const btn = document.getElementById('registerBtn');
|
||
const loading = document.getElementById('loading');
|
||
const result = document.getElementById('result');
|
||
const token = document.getElementById('token').value.trim();
|
||
|
||
console.log('[Form] Token length:', token.length);
|
||
console.log('[Form] Token value:', 'Bearer ' + token);
|
||
|
||
if (!token) {
|
||
result.className = 'error';
|
||
result.innerHTML = '<h3><3E><> Error</h3><p>Please enter the admin token</p>';
|
||
result.style.display = 'block';
|
||
return;
|
||
}
|
||
|
||
btn.disabled = true;
|
||
loading.style.display = 'block';
|
||
result.style.display = 'none';
|
||
|
||
try {
|
||
const authHeader = 'Bearer ' + token;
|
||
console.log('[Form] Sending auth header:', authHeader);
|
||
|
||
const response = await fetch('/api/discord/admin-register-commands', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Authorization': authHeader,
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify({ token: token })
|
||
});
|
||
|
||
let data;
|
||
const contentType = response.headers.get('content-type');
|
||
|
||
if (contentType && contentType.includes('application/json')) {
|
||
data = await response.json();
|
||
} else {
|
||
const text = await response.text();
|
||
data = { error: text };
|
||
}
|
||
|
||
loading.style.display = 'none';
|
||
result.style.display = 'block';
|
||
|
||
if (response.ok && data.ok) {
|
||
result.className = 'success';
|
||
result.innerHTML = \`
|
||
<h3>✅ Success!</h3>
|
||
<p><strong>Registered \${data.commands.length} commands:</strong></p>
|
||
<ul style="text-align: left;">
|
||
\${data.commands.map(cmd => \`<li>✓ /\${cmd.name}</li>\`).join('')}
|
||
</ul>
|
||
<p style="margin-top: 15px; color: #155724;">You can now use these commands in Discord!</p>
|
||
\`;
|
||
} else {
|
||
result.className = 'error';
|
||
result.innerHTML = \`
|
||
<h3>❌ Registration Failed (\${response.status})</h3>
|
||
<p><strong>Error:</strong> \${data.error || 'Unknown error'}</p>
|
||
\${data.details ? \`<p><strong>Details:</strong> \${data.details}</p>\` : ''}
|
||
\`;
|
||
}
|
||
} catch (error) {
|
||
loading.style.display = 'none';
|
||
result.style.display = 'block';
|
||
result.className = 'error';
|
||
result.innerHTML = \`
|
||
<h3>❌ Request Failed</h3>
|
||
<p>\${error.message}</p>
|
||
\`;
|
||
} finally {
|
||
btn.disabled = false;
|
||
}
|
||
}
|
||
|
||
// Auto-focus token field on page load
|
||
window.addEventListener('load', () => {
|
||
document.getElementById('token').focus();
|
||
});
|
||
</script>
|
||
</body>
|
||
</html>
|
||
`);
|
||
});
|
||
|
||
app.post("/api/discord/admin-register-commands", async (req, res) => {
|
||
try {
|
||
// Skip auth for localhost/development
|
||
const isLocalhost =
|
||
req.hostname === "localhost" || req.hostname === "127.0.0.1";
|
||
|
||
if (!isLocalhost) {
|
||
const authHeader = req.headers.authorization;
|
||
const tokenFromBody = req.body?.token as string;
|
||
|
||
// Extract token from Bearer header
|
||
let token = null;
|
||
if (authHeader && authHeader.startsWith("Bearer ")) {
|
||
token = authHeader.substring(7);
|
||
} else if (tokenFromBody) {
|
||
token = tokenFromBody;
|
||
}
|
||
|
||
const adminToken = process.env.DISCORD_ADMIN_REGISTER_TOKEN;
|
||
|
||
if (!adminToken || !token || token !== adminToken) {
|
||
console.error(
|
||
"[Discord] Authorization failed - token mismatch or missing",
|
||
);
|
||
return res.status(401).json({
|
||
error: "Unauthorized - invalid or missing admin token",
|
||
});
|
||
}
|
||
}
|
||
|
||
const botToken = process.env.DISCORD_BOT_TOKEN?.trim();
|
||
const clientId = process.env.DISCORD_CLIENT_ID?.trim();
|
||
|
||
console.log(
|
||
"[Discord] Config check - botToken set:",
|
||
!!botToken,
|
||
"clientId set:",
|
||
!!clientId,
|
||
"botToken length:",
|
||
botToken?.length,
|
||
);
|
||
|
||
// Log first and last few chars of token for debugging (safe logging)
|
||
if (botToken) {
|
||
const first = botToken.substring(0, 5);
|
||
const last = botToken.substring(botToken.length - 5);
|
||
console.log(
|
||
"[Discord] Token preview: " + first + "..." + last,
|
||
"Total length:",
|
||
botToken.length,
|
||
);
|
||
}
|
||
|
||
if (!botToken || !clientId) {
|
||
return res.status(500).json({
|
||
error: "Discord bot token or client ID not configured",
|
||
});
|
||
}
|
||
|
||
// Validate token format
|
||
if (botToken.length < 20) {
|
||
console.warn(
|
||
"[Discord] Bot token appears invalid - length:",
|
||
botToken.length,
|
||
);
|
||
return res.status(500).json({
|
||
error:
|
||
"Discord bot token appears invalid (check DISCORD_BOT_TOKEN in environment)",
|
||
tokenLength: botToken.length,
|
||
});
|
||
}
|
||
|
||
// Register slash commands
|
||
const commands = [
|
||
{
|
||
name: "verify",
|
||
description: "Link your Discord account to AeThex",
|
||
type: 1,
|
||
},
|
||
{
|
||
name: "set-realm",
|
||
description: "Choose your primary arm/realm",
|
||
type: 1,
|
||
options: [
|
||
{
|
||
name: "realm",
|
||
description: "Select your primary realm",
|
||
type: 3,
|
||
required: true,
|
||
choices: [
|
||
{ name: "Labs", value: "labs" },
|
||
{ name: "GameForge", value: "gameforge" },
|
||
{ name: "Corp", value: "corp" },
|
||
{ name: "Foundation", value: "foundation" },
|
||
{ name: "Nexus", value: "nexus" },
|
||
],
|
||
},
|
||
],
|
||
},
|
||
{
|
||
name: "profile",
|
||
description: "View your AeThex profile",
|
||
type: 1,
|
||
},
|
||
{
|
||
name: "unlink",
|
||
description: "Disconnect your Discord account from AeThex",
|
||
type: 1,
|
||
},
|
||
{
|
||
name: "verify-role",
|
||
description: "Check your assigned Discord roles",
|
||
type: 1,
|
||
},
|
||
];
|
||
|
||
const registerUrl = `https://discord.com/api/v10/applications/${clientId}/commands`;
|
||
console.log("[Discord] Calling Discord API:", registerUrl);
|
||
console.log("[Discord] Bot token length:", botToken.length);
|
||
console.log("[Discord] Sending", commands.length, "commands");
|
||
|
||
const response = await fetch(registerUrl, {
|
||
method: "PUT",
|
||
headers: {
|
||
Authorization: `Bot ${botToken}`,
|
||
"Content-Type": "application/json",
|
||
},
|
||
body: JSON.stringify(commands),
|
||
});
|
||
|
||
console.log("[Discord] Discord API response status:", response.status);
|
||
|
||
if (!response.ok) {
|
||
const errorData = await response.text();
|
||
let errorJson = {};
|
||
try {
|
||
errorJson = JSON.parse(errorData);
|
||
} catch {}
|
||
|
||
console.error(
|
||
"[Discord] Command registration failed:",
|
||
response.status,
|
||
errorData,
|
||
);
|
||
|
||
return res.status(500).json({
|
||
error: `Discord API error (${response.status}): ${errorData}`,
|
||
discordError: errorJson,
|
||
});
|
||
}
|
||
|
||
const result = await response.json();
|
||
console.log(
|
||
"[Discord] Commands registered successfully:",
|
||
result.length,
|
||
);
|
||
res.setHeader("Content-Type", "application/json");
|
||
return res.json({
|
||
ok: true,
|
||
message: `Registered ${result.length} commands`,
|
||
commands: result,
|
||
});
|
||
} catch (e: any) {
|
||
console.error(
|
||
"[Discord] Exception registering commands:",
|
||
e.message,
|
||
e.stack,
|
||
);
|
||
res.setHeader("Content-Type", "application/json");
|
||
return res.status(500).json({
|
||
error: e?.message || "Failed to register commands",
|
||
details:
|
||
process.env.NODE_ENV === "development" ? e?.stack : undefined,
|
||
});
|
||
}
|
||
});
|
||
|
||
// Discord Bot Health Check (proxy to avoid CSP issues)
|
||
// Set DISCORD_BOT_HEALTH_URL env var to bot's actual health endpoint
|
||
// Examples:
|
||
// - Railway internal: http://aethex.railway.internal:8044/health
|
||
// - External URL: https://bot.example.com/health
|
||
// - Local: http://localhost:3000/health
|
||
app.get("/api/discord/bot-health", async (req, res) => {
|
||
try {
|
||
// Try multiple bot health URLs in order of preference
|
||
const botHealthUrls = [
|
||
process.env.DISCORD_BOT_HEALTH_URL,
|
||
"http://aethex.railway.internal:8044/health", // Railway internal network
|
||
"http://localhost:8044/health", // Local fallback
|
||
].filter(Boolean) as string[];
|
||
|
||
let lastError: Error | null = null;
|
||
let response: Response | null = null;
|
||
|
||
for (const url of botHealthUrls) {
|
||
try {
|
||
console.log(`[Discord Bot Health] Attempting to reach: ${url}`);
|
||
// Create AbortController with 5 second timeout for internal Railway, 3 seconds for localhost
|
||
const isInternal = url.includes("railway.internal");
|
||
const timeoutMs = isInternal ? 5000 : 3000;
|
||
const controller = new AbortController();
|
||
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
||
|
||
try {
|
||
response = await fetch(url, {
|
||
method: "GET",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
},
|
||
signal: controller.signal,
|
||
});
|
||
} finally {
|
||
clearTimeout(timeoutId);
|
||
}
|
||
|
||
const contentType = response.headers.get("content-type") || "";
|
||
const responseBody = await response.text();
|
||
|
||
console.log(
|
||
`[Discord Bot Health] Response from ${url}: Status ${response.status}, Content-Type: ${contentType}, Body: ${responseBody.substring(0, 200)}`,
|
||
);
|
||
|
||
if (response.ok && contentType.includes("application/json")) {
|
||
console.log(`[Discord Bot Health] Success from ${url}`);
|
||
const data = JSON.parse(responseBody);
|
||
res.setHeader("Content-Type", "application/json");
|
||
return res.json({
|
||
status: data.status || "online",
|
||
guilds: data.guilds || 0,
|
||
commands: data.commands || 0,
|
||
uptime: data.uptime || 0,
|
||
timestamp: data.timestamp || new Date().toISOString(),
|
||
});
|
||
}
|
||
|
||
if (response.ok && !contentType.includes("application/json")) {
|
||
lastError = new Error(
|
||
`Got non-JSON response (${contentType}): ${responseBody.substring(0, 100)}`,
|
||
);
|
||
continue;
|
||
}
|
||
} catch (err) {
|
||
lastError = err instanceof Error ? err : new Error(String(err));
|
||
console.warn(
|
||
`[Discord Bot Health] Failed to reach ${url}: ${lastError.message}`,
|
||
);
|
||
continue;
|
||
}
|
||
}
|
||
|
||
// Could not reach any health endpoint
|
||
res.setHeader("Content-Type", "application/json");
|
||
return res.status(503).json({
|
||
status: "offline",
|
||
error: `Could not reach bot health endpoint. Last error: ${lastError?.message || "Unknown error"}`,
|
||
});
|
||
} catch (error: any) {
|
||
console.error("[Discord Bot Health] Error:", error);
|
||
res.setHeader("Content-Type", "application/json");
|
||
return res.status(500).json({
|
||
status: "offline",
|
||
error:
|
||
error instanceof Error
|
||
? error.message
|
||
: "Failed to reach bot health endpoint",
|
||
});
|
||
}
|
||
});
|
||
|
||
// Discord Token Diagnostic Endpoint
|
||
app.get("/api/discord/diagnostic", async (req, res) => {
|
||
try {
|
||
const botToken = process.env.DISCORD_BOT_TOKEN?.trim();
|
||
const clientId = process.env.DISCORD_CLIENT_ID?.trim();
|
||
const publicKey = process.env.DISCORD_PUBLIC_KEY?.trim();
|
||
|
||
const diagnostics = {
|
||
timestamp: new Date().toISOString(),
|
||
environment: {
|
||
botTokenSet: !!botToken,
|
||
clientIdSet: !!clientId,
|
||
publicKeySet: !!publicKey,
|
||
},
|
||
tokenValidation: {
|
||
length: botToken?.length || 0,
|
||
format: botToken ? "valid_format" : "missing",
|
||
preview: botToken
|
||
? `${botToken.substring(0, 15)}...${botToken.substring(botToken.length - 10)}`
|
||
: null,
|
||
minLengthMet: (botToken?.length || 0) >= 20,
|
||
},
|
||
clientIdValidation: {
|
||
value: clientId || null,
|
||
isNumeric: /^\d+$/.test(clientId || ""),
|
||
},
|
||
testRequest: {
|
||
url: `https://discord.com/api/v10/applications/${clientId}/commands`,
|
||
method: "PUT",
|
||
headerFormat: "Bot {token}",
|
||
status: "ready_to_test",
|
||
},
|
||
recommendations: [] as string[],
|
||
};
|
||
|
||
// Add recommendations based on validation
|
||
if (!botToken) {
|
||
diagnostics.recommendations.push(
|
||
"❌ DISCORD_BOT_TOKEN not set. Set it in environment variables.",
|
||
);
|
||
} else if ((botToken?.length || 0) < 20) {
|
||
diagnostics.recommendations.push(
|
||
`❌ DISCORD_BOT_TOKEN appears invalid (length: ${botToken?.length}). Should be 60+ characters.`,
|
||
);
|
||
} else {
|
||
diagnostics.recommendations.push(
|
||
"✅ DISCORD_BOT_TOKEN format looks valid",
|
||
);
|
||
}
|
||
|
||
if (!clientId) {
|
||
diagnostics.recommendations.push(
|
||
"<22><> DISCORD_CLIENT_ID not set. Set it to your application's ID.",
|
||
);
|
||
} else {
|
||
diagnostics.recommendations.push("<22><> DISCORD_CLIENT_ID is set");
|
||
}
|
||
|
||
if (!publicKey) {
|
||
diagnostics.recommendations.push(
|
||
"<22><><EFBFBD> DISCORD_PUBLIC_KEY not set. Needed for signature verification.",
|
||
);
|
||
} else {
|
||
diagnostics.recommendations.push("✅ DISCORD_PUBLIC_KEY is set");
|
||
}
|
||
|
||
// Test if token works with Discord API
|
||
if (botToken && clientId) {
|
||
try {
|
||
const testResponse = await fetch(
|
||
`https://discord.com/api/v10/applications/${clientId}`,
|
||
{
|
||
headers: {
|
||
Authorization: `Bot ${botToken}`,
|
||
},
|
||
},
|
||
);
|
||
|
||
diagnostics.testRequest = {
|
||
...diagnostics.testRequest,
|
||
status:
|
||
testResponse.status === 200
|
||
? "✅ Success"
|
||
: `❌ Failed (${testResponse.status})`,
|
||
responseCode: testResponse.status,
|
||
};
|
||
|
||
if (testResponse.status === 401) {
|
||
diagnostics.recommendations.push(
|
||
"❌ Token authentication failed (401). The token may be invalid or revoked.",
|
||
);
|
||
} else if (testResponse.status === 200) {
|
||
diagnostics.recommendations.push(
|
||
"✅ Token authentication successful with Discord API!",
|
||
);
|
||
}
|
||
} catch (error) {
|
||
diagnostics.testRequest = {
|
||
...diagnostics.testRequest,
|
||
status: "⚠️ Network Error",
|
||
error: error instanceof Error ? error.message : "Unknown error",
|
||
};
|
||
}
|
||
}
|
||
|
||
res.setHeader("Content-Type", "application/json");
|
||
return res.json(diagnostics);
|
||
} catch (error: any) {
|
||
console.error("[Discord Diagnostic] Error:", error);
|
||
res.setHeader("Content-Type", "application/json");
|
||
return res.status(500).json({
|
||
error: error instanceof Error ? error.message : "Diagnostic failed",
|
||
});
|
||
}
|
||
});
|
||
|
||
// Site settings (admin-managed)
|
||
app.get("/api/site-settings", async (req, res) => {
|
||
try {
|
||
const key = String(req.query.key || "").trim();
|
||
if (key) {
|
||
try {
|
||
const { data, error } = await adminSupabase
|
||
.from("site_settings")
|
||
.select("value")
|
||
.eq("key", key)
|
||
.maybeSingle();
|
||
if (error) {
|
||
if (isTableMissing(error)) return res.json({});
|
||
return res.status(500).json({ error: error.message });
|
||
}
|
||
return res.json((data as any)?.value || {});
|
||
} catch (e: any) {
|
||
return res.status(500).json({ error: e?.message || String(e) });
|
||
}
|
||
}
|
||
const { data, error } = await adminSupabase
|
||
.from("site_settings")
|
||
.select("key, value");
|
||
if (error) {
|
||
if (isTableMissing(error)) return res.json({});
|
||
return res.status(500).json({ error: error.message });
|
||
}
|
||
const map: Record<string, any> = {};
|
||
for (const row of data || [])
|
||
map[(row as any).key] = (row as any).value;
|
||
return res.json(map);
|
||
} catch (e: any) {
|
||
return res.status(500).json({ error: e?.message || String(e) });
|
||
}
|
||
});
|
||
|
||
app.post("/api/site-settings", async (req, res) => {
|
||
try {
|
||
const { key, value } = (req.body || {}) as {
|
||
key?: string;
|
||
value?: any;
|
||
};
|
||
if (!key || typeof key !== "string") {
|
||
return res.status(400).json({ error: "key required" });
|
||
}
|
||
const payload = { key, value: value ?? {} } as any;
|
||
const { error } = await adminSupabase
|
||
.from("site_settings")
|
||
.upsert(payload, { onConflict: "key" as any });
|
||
if (error) {
|
||
if (isTableMissing(error))
|
||
return res
|
||
.status(400)
|
||
.json({ error: "site_settings table missing" });
|
||
return res.status(500).json({ error: error.message });
|
||
}
|
||
return res.json({ ok: true });
|
||
} catch (e: any) {
|
||
return res.status(500).json({ error: e?.message || String(e) });
|
||
}
|
||
});
|
||
|
||
app.get("/api/health", async (_req, res) => {
|
||
try {
|
||
const { error } = await adminSupabase
|
||
.from("user_profiles")
|
||
.select("count", { count: "exact", head: true });
|
||
if (error)
|
||
return res.status(500).json({ ok: false, error: error.message });
|
||
return res.json({ ok: true });
|
||
} catch (e: any) {
|
||
return res
|
||
.status(500)
|
||
.json({ ok: false, error: e?.message || String(e) });
|
||
}
|
||
});
|
||
|
||
app.get("/api/posts", async (req, res) => {
|
||
const limit = Math.max(1, Math.min(50, Number(req.query.limit) || 10));
|
||
try {
|
||
const { data, error } = await adminSupabase
|
||
.from("community_posts")
|
||
.select(`*, user_profiles ( username, full_name, avatar_url )`)
|
||
.eq("is_published", true)
|
||
.order("created_at", { ascending: false })
|
||
.limit(limit);
|
||
if (error) return res.status(500).json({ error: error.message });
|
||
res.json(data || []);
|
||
} catch (e: any) {
|
||
res.status(500).json({ error: e?.message || String(e) });
|
||
}
|
||
});
|
||
|
||
app.get("/api/user/:id/posts", async (req, res) => {
|
||
const userId = req.params.id;
|
||
try {
|
||
const { data, error } = await adminSupabase
|
||
.from("community_posts")
|
||
.select("*")
|
||
.eq("author_id", userId)
|
||
.order("created_at", { ascending: false });
|
||
if (error) return res.status(500).json({ error: error.message });
|
||
res.json(data || []);
|
||
} catch (e: any) {
|
||
res.status(500).json({ error: e?.message || String(e) });
|
||
}
|
||
});
|
||
|
||
app.post("/api/posts", async (req, res) => {
|
||
console.log("[API] POST /api/posts called");
|
||
const payload = req.body || {};
|
||
console.log("[API] Payload:", JSON.stringify(payload).slice(0, 200));
|
||
|
||
// Validation
|
||
if (!payload.author_id) {
|
||
console.log("[API] Error: author_id is required");
|
||
return res.status(400).json({ error: "author_id is required" });
|
||
}
|
||
if (
|
||
!payload.title ||
|
||
typeof payload.title !== "string" ||
|
||
!payload.title.trim()
|
||
) {
|
||
return res
|
||
.status(400)
|
||
.json({ error: "title is required and must be a non-empty string" });
|
||
}
|
||
if (
|
||
!payload.content ||
|
||
typeof payload.content !== "string" ||
|
||
!payload.content.trim()
|
||
) {
|
||
return res.status(400).json({
|
||
error: "content is required and must be a non-empty string",
|
||
});
|
||
}
|
||
|
||
// Validate author_id is a valid UUID format
|
||
const uuidRegex =
|
||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||
if (!uuidRegex.test(String(payload.author_id))) {
|
||
return res
|
||
.status(400)
|
||
.json({ error: "author_id must be a valid UUID" });
|
||
}
|
||
|
||
try {
|
||
// Verify author exists
|
||
const { data: author, error: authorError } = await adminSupabase
|
||
.from("user_profiles")
|
||
.select("id")
|
||
.eq("id", payload.author_id)
|
||
.single();
|
||
|
||
if (authorError || !author) {
|
||
return res.status(404).json({ error: "Author not found" });
|
||
}
|
||
|
||
const { data, error } = await adminSupabase
|
||
.from("community_posts")
|
||
.insert({
|
||
author_id: payload.author_id,
|
||
title: String(payload.title).trim(),
|
||
content: String(payload.content).trim(),
|
||
category: payload.category ? String(payload.category).trim() : null,
|
||
tags: Array.isArray(payload.tags)
|
||
? payload.tags.map((t: any) => String(t).trim())
|
||
: [],
|
||
is_published: payload.is_published ?? true,
|
||
})
|
||
.select()
|
||
.single();
|
||
|
||
if (error) {
|
||
console.error("[API] /api/posts insert error:", {
|
||
code: error.code,
|
||
message: error.message,
|
||
details: (error as any).details,
|
||
});
|
||
return res
|
||
.status(500)
|
||
.json({ error: error.message || "Failed to create post" });
|
||
}
|
||
|
||
console.log("[API] Post created successfully:", data?.id);
|
||
res.json(data);
|
||
} catch (e: any) {
|
||
console.error("[API] /api/posts exception:", e?.message || String(e));
|
||
res.status(500).json({ error: e?.message || "Failed to create post" });
|
||
}
|
||
});
|
||
|
||
app.post("/api/profile/ensure", async (req, res) => {
|
||
const { id, profile } = req.body || {};
|
||
console.log("[API] /api/profile/ensure called", { id, profile });
|
||
if (!id) return res.status(400).json({ error: "missing id" });
|
||
|
||
const tryUpsert = async (payload: any) => {
|
||
const resp = await adminSupabase
|
||
.from("user_profiles")
|
||
.upsert(payload, { onConflict: "id" })
|
||
.select()
|
||
.single();
|
||
return resp as any;
|
||
};
|
||
|
||
try {
|
||
let username = profile?.username;
|
||
let attempt = await tryUpsert({ id, ...profile, username });
|
||
|
||
const normalizeError = (err: any) => {
|
||
if (!err) return null;
|
||
if (typeof err === "string") return { message: err };
|
||
if (typeof err === "object" && Object.keys(err).length === 0)
|
||
return null; // treat empty object as no error
|
||
return err;
|
||
};
|
||
|
||
let error = normalizeError(attempt.error);
|
||
if (error) {
|
||
console.error("[API] ensure upsert error:", {
|
||
message: (error as any).message,
|
||
code: (error as any).code,
|
||
details: (error as any).details,
|
||
hint: (error as any).hint,
|
||
});
|
||
|
||
const message: string = (error as any).message || "";
|
||
const code: string = (error as any).code || "";
|
||
|
||
// Handle duplicate username
|
||
if (
|
||
code === "23505" ||
|
||
message.includes("duplicate key") ||
|
||
message.includes("username")
|
||
) {
|
||
const suffix = Math.random().toString(36).slice(2, 6);
|
||
const newUsername = `${String(username || "user").slice(0, 20)}_${suffix}`;
|
||
console.log("[API] retrying with unique username", newUsername);
|
||
attempt = await tryUpsert({
|
||
id,
|
||
...profile,
|
||
username: newUsername,
|
||
});
|
||
error = normalizeError(attempt.error);
|
||
}
|
||
}
|
||
|
||
if (error) {
|
||
// Possible foreign key violation: auth.users missing
|
||
if (
|
||
(error as any).code === "23503" ||
|
||
(error as any).message?.includes("foreign key")
|
||
) {
|
||
return res.status(400).json({
|
||
error:
|
||
"User does not exist in authentication system. Please sign out and sign back in, then retry onboarding.",
|
||
});
|
||
}
|
||
return res.status(500).json({
|
||
error:
|
||
(error as any).message ||
|
||
JSON.stringify(error) ||
|
||
"Unknown error",
|
||
});
|
||
}
|
||
|
||
return res.json(attempt.data || {});
|
||
} catch (e: any) {
|
||
console.error("[API] /api/profile/ensure exception:", e);
|
||
res.status(500).json({ error: e?.message || String(e) });
|
||
}
|
||
});
|
||
|
||
// Wallet verification endpoint for Phase 2 Bridge UI
|
||
app.post("/api/profile/wallet-verify", async (req, res) => {
|
||
const { user_id, wallet_address } = req.body || {};
|
||
|
||
if (!user_id) {
|
||
return res.status(400).json({ error: "user_id is required" });
|
||
}
|
||
|
||
if (!wallet_address) {
|
||
return res.status(400).json({ error: "wallet_address is required" });
|
||
}
|
||
|
||
// Validate Ethereum address format (0x followed by 40 hex chars)
|
||
const ethAddressRegex = /^0x[a-fA-F0-9]{40}$/;
|
||
const normalizedAddress = String(wallet_address).toLowerCase();
|
||
|
||
if (!ethAddressRegex.test(normalizedAddress)) {
|
||
return res.status(400).json({
|
||
error: "Invalid Ethereum address format",
|
||
});
|
||
}
|
||
|
||
try {
|
||
// Check if wallet is already connected to a different user
|
||
const { data: existingUser, error: checkError } = await adminSupabase
|
||
.from("user_profiles")
|
||
.select("id, username")
|
||
.eq("wallet_address", normalizedAddress)
|
||
.neq("id", user_id)
|
||
.maybeSingle();
|
||
|
||
if (checkError && checkError.code !== "PGRST116") {
|
||
// PGRST116 = no rows returned (expected)
|
||
console.error("[Wallet Verify] Check error:", checkError);
|
||
return res.status(500).json({
|
||
error: "Failed to verify wallet availability",
|
||
});
|
||
}
|
||
|
||
if (existingUser) {
|
||
return res.status(409).json({
|
||
error: "This wallet is already connected to another account",
|
||
});
|
||
}
|
||
|
||
// Update user profile with wallet address
|
||
const { data, error } = await adminSupabase
|
||
.from("user_profiles")
|
||
.update({ wallet_address: normalizedAddress })
|
||
.eq("id", user_id)
|
||
.select()
|
||
.single();
|
||
|
||
if (error) {
|
||
console.error("[Wallet Verify] Update error:", error);
|
||
return res.status(500).json({
|
||
error: error.message || "Failed to connect wallet",
|
||
});
|
||
}
|
||
|
||
console.log("[Wallet Verify] Wallet connected for user:", user_id);
|
||
|
||
return res.json({
|
||
ok: true,
|
||
message: "Wallet connected successfully",
|
||
wallet_address: normalizedAddress,
|
||
user: data,
|
||
});
|
||
} catch (e: any) {
|
||
console.error("[Wallet Verify] Exception:", e?.message);
|
||
return res.status(500).json({
|
||
error: e?.message || "Failed to connect wallet",
|
||
});
|
||
}
|
||
});
|
||
|
||
// Wallet disconnection endpoint
|
||
app.delete("/api/profile/wallet-verify", async (req, res) => {
|
||
const { user_id } = req.body || {};
|
||
|
||
if (!user_id) {
|
||
return res.status(400).json({ error: "user_id is required" });
|
||
}
|
||
|
||
try {
|
||
const { data, error } = await adminSupabase
|
||
.from("user_profiles")
|
||
.update({ wallet_address: null })
|
||
.eq("id", user_id)
|
||
.select()
|
||
.single();
|
||
|
||
if (error) {
|
||
console.error("[Wallet Verify] Disconnect error:", error);
|
||
return res.status(500).json({
|
||
error: error.message || "Failed to disconnect wallet",
|
||
});
|
||
}
|
||
|
||
console.log("[Wallet Verify] Wallet disconnected for user:", user_id);
|
||
|
||
return res.json({
|
||
ok: true,
|
||
message: "Wallet disconnected successfully",
|
||
user: data,
|
||
});
|
||
} catch (e: any) {
|
||
console.error("[Wallet Verify] Disconnect exception:", e?.message);
|
||
return res.status(500).json({
|
||
error: e?.message || "Failed to disconnect wallet",
|
||
});
|
||
}
|
||
});
|
||
|
||
app.post("/api/interests", async (req, res) => {
|
||
const { user_id, interests } = req.body || {};
|
||
if (!user_id || !Array.isArray(interests))
|
||
return res.status(400).json({ error: "invalid payload" });
|
||
try {
|
||
await adminSupabase
|
||
.from("user_interests")
|
||
.delete()
|
||
.eq("user_id", user_id);
|
||
if (interests.length) {
|
||
const rows = interests.map((interest: string) => ({
|
||
user_id,
|
||
interest,
|
||
}));
|
||
const { error } = await adminSupabase
|
||
.from("user_interests")
|
||
.insert(rows);
|
||
if (error) return res.status(500).json({ error: error.message });
|
||
}
|
||
res.json({ ok: true });
|
||
} catch (e: any) {
|
||
res.status(500).json({ error: e?.message || String(e) });
|
||
}
|
||
});
|
||
|
||
app.get("/api/featured-studios", async (_req, res) => {
|
||
try {
|
||
const { data, error } = await adminSupabase
|
||
.from("featured_studios")
|
||
.select("*")
|
||
.order("rank", { ascending: true, nullsFirst: true } as any)
|
||
.order("name", { ascending: true });
|
||
if (error) return res.status(500).json({ error: error.message });
|
||
res.json(data || []);
|
||
} catch (e: any) {
|
||
res.status(500).json({ error: e?.message || String(e) });
|
||
}
|
||
});
|
||
|
||
app.post("/api/featured-studios", async (req, res) => {
|
||
const studios = (req.body?.studios || []) as any[];
|
||
if (!Array.isArray(studios))
|
||
return res.status(400).json({ error: "studios must be an array" });
|
||
try {
|
||
const rows = studios.map((s: any, idx: number) => ({
|
||
id: s.id,
|
||
name: String(s.name || "").trim(),
|
||
tagline: s.tagline || null,
|
||
metrics: s.metrics || null,
|
||
specialties: Array.isArray(s.specialties) ? s.specialties : null,
|
||
rank: Number.isFinite(s.rank) ? s.rank : idx,
|
||
}));
|
||
const { error } = await adminSupabase
|
||
.from("featured_studios")
|
||
.upsert(rows as any, { onConflict: "name" as any });
|
||
if (error) return res.status(500).json({ error: error.message });
|
||
res.json({ ok: true, count: rows.length });
|
||
} catch (e: any) {
|
||
res.status(500).json({ error: e?.message || String(e) });
|
||
}
|
||
});
|
||
|
||
app.post("/api/achievements/award", async (req, res) => {
|
||
const { user_id, achievement_names } = req.body || {};
|
||
if (!user_id) return res.status(400).json({ error: "user_id required" });
|
||
const names: string[] =
|
||
Array.isArray(achievement_names) && achievement_names.length
|
||
? achievement_names
|
||
: ["Welcome to AeThex"];
|
||
try {
|
||
const { data: achievements, error: aErr } = await adminSupabase
|
||
.from("achievements")
|
||
.select("id, name")
|
||
.in("name", names);
|
||
if (aErr) return res.status(500).json({ error: aErr.message });
|
||
const rows = (achievements || []).map((a: any) => ({
|
||
user_id,
|
||
achievement_id: a.id,
|
||
}));
|
||
if (!rows.length) return res.json({ ok: true, awarded: [] });
|
||
const { error: iErr } = await adminSupabase
|
||
.from("user_achievements")
|
||
.upsert(rows, { onConflict: "user_id,achievement_id" as any });
|
||
if (iErr && iErr.code !== "23505")
|
||
return res.status(500).json({ error: iErr.message });
|
||
return res.json({ ok: true, awarded: rows.length });
|
||
} catch (e: any) {
|
||
console.error("[API] achievements/award exception", e);
|
||
return res.status(500).json({ error: e?.message || String(e) });
|
||
}
|
||
});
|
||
|
||
app.post("/api/achievements/activate", async (req, res) => {
|
||
try {
|
||
const CORE_ACHIEVEMENTS = [
|
||
{
|
||
id: "welcome-to-aethex",
|
||
name: "Welcome to AeThex",
|
||
description: "Completed onboarding and joined the AeThex network.",
|
||
icon: "🎉",
|
||
badge_color: "#7C3AED",
|
||
xp_reward: 250,
|
||
},
|
||
{
|
||
id: "aethex-explorer",
|
||
name: "AeThex Explorer",
|
||
description:
|
||
"Engaged with community initiatives and posted first update.",
|
||
icon: "🧭",
|
||
badge_color: "#0EA5E9",
|
||
xp_reward: 400,
|
||
},
|
||
{
|
||
id: "community-champion",
|
||
name: "Community Champion",
|
||
description:
|
||
"Contributed feedback, resolved bugs, and mentored squads.",
|
||
icon: "🏆",
|
||
badge_color: "#22C55E",
|
||
xp_reward: 750,
|
||
},
|
||
{
|
||
id: "workshop-architect",
|
||
name: "Workshop Architect",
|
||
description:
|
||
"Published a high-impact mod or toolkit adopted by teams.",
|
||
icon: "<22><>️",
|
||
badge_color: "#F97316",
|
||
xp_reward: 1200,
|
||
},
|
||
{
|
||
id: "god-mode",
|
||
name: "GOD Mode",
|
||
description:
|
||
"Legendary status awarded by AeThex studio leadership.",
|
||
icon: "⚡",
|
||
badge_color: "#FACC15",
|
||
xp_reward: 5000,
|
||
},
|
||
];
|
||
|
||
const nowIso = new Date().toISOString();
|
||
|
||
const achievementResults = await Promise.all(
|
||
CORE_ACHIEVEMENTS.map(async (achievement) => {
|
||
const { createHash } = await import("crypto");
|
||
const generateDeterministicUUID = (str: string): string => {
|
||
const hash = createHash("sha256").update(str).digest("hex");
|
||
return [
|
||
hash.slice(0, 8),
|
||
hash.slice(8, 12),
|
||
"5" + hash.slice(13, 16),
|
||
((parseInt(hash.slice(16, 18), 16) & 0x3f) | 0x80)
|
||
.toString(16)
|
||
.padStart(2, "0") + hash.slice(18, 20),
|
||
hash.slice(20, 32),
|
||
].join("-");
|
||
};
|
||
|
||
const uuidId = generateDeterministicUUID(achievement.id);
|
||
const { error } = await adminSupabase.from("achievements").upsert(
|
||
{
|
||
id: uuidId,
|
||
name: achievement.name,
|
||
description: achievement.description,
|
||
icon: achievement.icon,
|
||
badge_color: achievement.badge_color,
|
||
xp_reward: achievement.xp_reward,
|
||
},
|
||
{ onConflict: "id" },
|
||
);
|
||
|
||
if (error) {
|
||
console.error(
|
||
`Failed to upsert achievement ${achievement.id}:`,
|
||
error,
|
||
);
|
||
throw error;
|
||
}
|
||
return achievement.id;
|
||
}),
|
||
);
|
||
|
||
return res.json({
|
||
ok: true,
|
||
achievementsSeeded: achievementResults.length,
|
||
achievements: achievementResults,
|
||
});
|
||
} catch (error: any) {
|
||
console.error("activate achievements error", error);
|
||
return res.status(500).json({
|
||
error: error?.message || "Failed to activate achievements",
|
||
});
|
||
}
|
||
});
|
||
|
||
// Blog endpoints (Supabase-backed)
|
||
app.get("/api/blog", async (req, res) => {
|
||
const limit = Math.max(1, Math.min(200, Number(req.query.limit) || 50));
|
||
const category = String(req.query.category || "").trim();
|
||
try {
|
||
let query = adminSupabase
|
||
.from("blog_posts")
|
||
.select(
|
||
"id, slug, title, excerpt, author, date, read_time, category, image, likes, comments, published_at, body_html",
|
||
)
|
||
.order("published_at", { ascending: false, nullsLast: true } as any)
|
||
.limit(limit);
|
||
if (category) query = query.eq("category", category);
|
||
const { data, error } = await query;
|
||
if (error) {
|
||
// If table doesn't exist, return empty array (client will use seed data)
|
||
if (
|
||
error.message?.includes("does not exist") ||
|
||
error.code === "42P01"
|
||
) {
|
||
console.log(
|
||
"[Blog] blog_posts table not found, returning empty array",
|
||
);
|
||
return res.json([]);
|
||
}
|
||
console.error("[Blog] Error fetching blog posts:", error);
|
||
return res.status(500).json({ error: error.message });
|
||
}
|
||
console.log(
|
||
"[Blog] Successfully fetched",
|
||
(data || []).length,
|
||
"blog posts",
|
||
);
|
||
res.json(data || []);
|
||
} catch (e: any) {
|
||
console.error("[Blog] Exception:", e);
|
||
res.status(500).json({ error: e?.message || String(e) });
|
||
}
|
||
});
|
||
|
||
app.get("/api/blog/:slug", async (req, res) => {
|
||
const slug = String(req.params.slug || "");
|
||
if (!slug) return res.status(400).json({ error: "missing slug" });
|
||
try {
|
||
const { data, error } = await adminSupabase
|
||
.from("blog_posts")
|
||
.select(
|
||
"id, slug, title, excerpt, author, date, read_time, category, image, body_html, published_at",
|
||
)
|
||
.eq("slug", slug)
|
||
.single();
|
||
if (error) {
|
||
if (error.code === "PGRST116") {
|
||
// No rows returned - 404
|
||
return res.status(404).json({ error: "Blog post not found" });
|
||
}
|
||
if (
|
||
error.message?.includes("does not exist") ||
|
||
error.code === "42P01"
|
||
) {
|
||
// Table doesn't exist
|
||
return res.status(404).json({ error: "Blog not configured" });
|
||
}
|
||
return res.status(500).json({ error: error.message });
|
||
}
|
||
res.json(data || null);
|
||
} catch (e: any) {
|
||
console.error("[Blog] Error fetching blog post:", e);
|
||
res.status(500).json({ error: e?.message || String(e) });
|
||
}
|
||
});
|
||
|
||
app.post("/api/blog", async (req, res) => {
|
||
const p = req.body || {};
|
||
const row = {
|
||
slug: String(p.slug || "").trim(),
|
||
title: String(p.title || "").trim(),
|
||
excerpt: p.excerpt || null,
|
||
author: p.author || null,
|
||
date: p.date || null,
|
||
read_time: p.read_time || null,
|
||
category: p.category || null,
|
||
image: p.image || null,
|
||
likes: Number.isFinite(p.likes) ? p.likes : 0,
|
||
comments: Number.isFinite(p.comments) ? p.comments : 0,
|
||
body_html: p.body_html || null,
|
||
published_at: p.published_at || new Date().toISOString(),
|
||
} as any;
|
||
if (!row.slug || !row.title)
|
||
return res.status(400).json({ error: "slug and title required" });
|
||
try {
|
||
const { data, error } = await adminSupabase
|
||
.from("blog_posts")
|
||
.upsert(row, { onConflict: "slug" as any })
|
||
.select()
|
||
.single();
|
||
if (error) return res.status(500).json({ error: error.message });
|
||
res.json(data || row);
|
||
} catch (e: any) {
|
||
res.status(500).json({ error: e?.message || String(e) });
|
||
}
|
||
});
|
||
|
||
app.delete("/api/blog/:slug", async (req, res) => {
|
||
const slug = String(req.params.slug || "");
|
||
if (!slug) return res.status(400).json({ error: "missing slug" });
|
||
try {
|
||
const { error } = await adminSupabase
|
||
.from("blog_posts")
|
||
.delete()
|
||
.eq("slug", slug);
|
||
if (error) return res.status(500).json({ error: error.message });
|
||
res.json({ ok: true });
|
||
} catch (e: any) {
|
||
res.status(500).json({ error: e?.message || String(e) });
|
||
}
|
||
});
|
||
|
||
app.get("/api/applications-old", async (req, res) => {
|
||
const owner = String(req.query.owner || "");
|
||
if (!owner) return res.status(400).json({ error: "owner required" });
|
||
try {
|
||
const { data, error } = await adminSupabase
|
||
.from("aethex_applications")
|
||
.select(`*`)
|
||
.eq("creator_id", owner)
|
||
.order("applied_at", { ascending: false })
|
||
.limit(50);
|
||
if (error) return res.status(500).json({ error: error.message });
|
||
res.json(data || []);
|
||
} catch (e: any) {
|
||
res.status(500).json({ error: e?.message || String(e) });
|
||
}
|
||
});
|
||
|
||
app.get("/api/opportunities/applications", async (req, res) => {
|
||
const requester = String(req.query.email || "").toLowerCase();
|
||
if (!requester || requester !== ownerEmail) {
|
||
return res.status(403).json({ error: "forbidden" });
|
||
}
|
||
try {
|
||
const { data, error } = await adminSupabase
|
||
.from("applications")
|
||
.select("*")
|
||
.order("submitted_at", { ascending: false })
|
||
.limit(200);
|
||
if (error) {
|
||
if (isTableMissing(error)) {
|
||
return res.json([]);
|
||
}
|
||
return res.status(500).json({ error: error.message });
|
||
}
|
||
return res.json(data || []);
|
||
} catch (e: any) {
|
||
return res.status(500).json({ error: e?.message || String(e) });
|
||
}
|
||
});
|
||
|
||
// Invites API
|
||
const baseUrl =
|
||
process.env.PUBLIC_BASE_URL ||
|
||
process.env.SITE_URL ||
|
||
"https://aethex.biz";
|
||
|
||
const safeEmail = (v?: string | null) => (v || "").trim().toLowerCase();
|
||
|
||
const accrue = async (
|
||
userId: string,
|
||
kind: "xp" | "loyalty" | "reputation",
|
||
amount: number,
|
||
type: string,
|
||
meta?: any,
|
||
) => {
|
||
const amt = Math.max(0, Math.floor(amount));
|
||
try {
|
||
await adminSupabase.from("reward_events").insert({
|
||
user_id: userId,
|
||
type,
|
||
points_kind: kind,
|
||
amount: amt,
|
||
metadata: meta || null,
|
||
});
|
||
} catch {}
|
||
|
||
const col =
|
||
kind === "xp"
|
||
? "total_xp"
|
||
: kind === "loyalty"
|
||
? "loyalty_points"
|
||
: "reputation_score";
|
||
const { data: row } = await adminSupabase
|
||
.from("user_profiles")
|
||
.select(`id, ${col}, level`)
|
||
.eq("id", userId)
|
||
.maybeSingle();
|
||
const current = Number((row as any)?.[col] || 0);
|
||
const updates: any = { [col]: current + amt };
|
||
if (col === "total_xp") {
|
||
const total = current + amt;
|
||
updates.level = Math.max(1, Math.floor(total / 1000) + 1);
|
||
}
|
||
await adminSupabase
|
||
.from("user_profiles")
|
||
.update(updates)
|
||
.eq("id", userId);
|
||
};
|
||
|
||
app.post("/api/invites", async (req, res) => {
|
||
const { inviter_id, invitee_email, message } = (req.body || {}) as {
|
||
inviter_id?: string;
|
||
invitee_email?: string;
|
||
message?: string | null;
|
||
};
|
||
if (!inviter_id || !invitee_email) {
|
||
return res
|
||
.status(400)
|
||
.json({ error: "inviter_id and invitee_email are required" });
|
||
}
|
||
const email = safeEmail(invitee_email);
|
||
const token = randomUUID();
|
||
try {
|
||
const { data: inviterProfile } = await adminSupabase
|
||
.from("user_profiles")
|
||
.select("full_name, username")
|
||
.eq("id", inviter_id)
|
||
.maybeSingle();
|
||
const inviterName =
|
||
(inviterProfile as any)?.full_name ||
|
||
(inviterProfile as any)?.username ||
|
||
"An AeThex member";
|
||
|
||
const { data, error } = await adminSupabase
|
||
.from("invites")
|
||
.insert({
|
||
inviter_id,
|
||
invitee_email: email,
|
||
token,
|
||
message: message || null,
|
||
status: "pending",
|
||
})
|
||
.select()
|
||
.single();
|
||
if (error) return res.status(500).json({ error: error.message });
|
||
|
||
const inviteUrl = `${baseUrl}/login?invite=${encodeURIComponent(token)}`;
|
||
|
||
if (emailService.isConfigured) {
|
||
try {
|
||
await emailService.sendInviteEmail({
|
||
to: email,
|
||
inviteUrl,
|
||
inviterName,
|
||
message: message || null,
|
||
});
|
||
} catch (e: any) {
|
||
console.warn("Failed to send invite email", e?.message || e);
|
||
}
|
||
}
|
||
|
||
await accrue(inviter_id, "loyalty", 5, "invite_sent", {
|
||
invitee: email,
|
||
});
|
||
try {
|
||
await adminSupabase.from("notifications").insert({
|
||
user_id: inviter_id,
|
||
type: "info",
|
||
title: "Invite sent",
|
||
message: `Invitation sent to ${email}`,
|
||
});
|
||
} catch {}
|
||
|
||
return res.json({ ok: true, invite: data, inviteUrl, token });
|
||
} catch (e: any) {
|
||
return res.status(500).json({ error: e?.message || String(e) });
|
||
}
|
||
});
|
||
|
||
app.get("/api/invites", async (req, res) => {
|
||
const inviter = String(req.query.inviter_id || "");
|
||
if (!inviter)
|
||
return res.status(400).json({ error: "inviter_id required" });
|
||
try {
|
||
const { data, error } = await adminSupabase
|
||
.from("invites")
|
||
.select("*")
|
||
.eq("inviter_id", inviter)
|
||
.order("created_at", { ascending: false });
|
||
if (error) return res.status(500).json({ error: error.message });
|
||
return res.json(data || []);
|
||
} catch (e: any) {
|
||
return res.status(500).json({ error: e?.message || String(e) });
|
||
}
|
||
});
|
||
|
||
app.post("/api/invites/accept", async (req, res) => {
|
||
const { token, acceptor_id } = (req.body || {}) as {
|
||
token?: string;
|
||
acceptor_id?: string;
|
||
};
|
||
if (!token || !acceptor_id) {
|
||
return res
|
||
.status(400)
|
||
.json({ error: "token and acceptor_id required" });
|
||
}
|
||
try {
|
||
const { data: invite, error } = await adminSupabase
|
||
.from("invites")
|
||
.select("*")
|
||
.eq("token", token)
|
||
.eq("status", "pending")
|
||
.maybeSingle();
|
||
if (error) return res.status(500).json({ error: error.message });
|
||
if (!invite) return res.status(404).json({ error: "invalid_invite" });
|
||
|
||
const now = new Date().toISOString();
|
||
const { error: upErr } = await adminSupabase
|
||
.from("invites")
|
||
.update({
|
||
status: "accepted",
|
||
accepted_by: acceptor_id,
|
||
accepted_at: now,
|
||
})
|
||
.eq("id", (invite as any).id);
|
||
if (upErr) return res.status(500).json({ error: upErr.message });
|
||
|
||
const inviterId = (invite as any).inviter_id as string;
|
||
if (inviterId && inviterId !== acceptor_id) {
|
||
await adminSupabase
|
||
.from("user_connections")
|
||
.upsert({ user_id: inviterId, connection_id: acceptor_id } as any)
|
||
.catch(() => undefined);
|
||
await adminSupabase
|
||
.from("user_connections")
|
||
.upsert({ user_id: acceptor_id, connection_id: inviterId } as any)
|
||
.catch(() => undefined);
|
||
}
|
||
|
||
if (inviterId) {
|
||
await accrue(inviterId, "xp", 100, "invite_accepted", { token });
|
||
await accrue(inviterId, "loyalty", 50, "invite_accepted", { token });
|
||
await accrue(inviterId, "reputation", 2, "invite_accepted", {
|
||
token,
|
||
});
|
||
try {
|
||
await adminSupabase.from("notifications").insert({
|
||
user_id: inviterId,
|
||
type: "success",
|
||
title: "Invite accepted",
|
||
message: "Your invitation was accepted. You're now connected.",
|
||
});
|
||
} catch {}
|
||
}
|
||
await accrue(acceptor_id, "xp", 50, "invite_accepted", { token });
|
||
await accrue(acceptor_id, "reputation", 1, "invite_accepted", {
|
||
token,
|
||
});
|
||
try {
|
||
await adminSupabase.from("notifications").insert({
|
||
user_id: acceptor_id,
|
||
type: "success",
|
||
title: "Connected",
|
||
message: "Connection established via invitation.",
|
||
});
|
||
} catch {}
|
||
|
||
return res.json({ ok: true });
|
||
} catch (e: any) {
|
||
return res.status(500).json({ error: e?.message || String(e) });
|
||
}
|
||
});
|
||
|
||
// Follow/unfollow with notifications
|
||
app.post("/api/social/follow", async (req, res) => {
|
||
const { follower_id, following_id } = (req.body || {}) as {
|
||
follower_id?: string;
|
||
following_id?: string;
|
||
};
|
||
if (!follower_id || !following_id)
|
||
return res
|
||
.status(400)
|
||
.json({ error: "follower_id and following_id required" });
|
||
try {
|
||
await adminSupabase
|
||
.from("user_follows")
|
||
.upsert({ follower_id, following_id } as any, {
|
||
onConflict: "follower_id,following_id" as any,
|
||
});
|
||
await accrue(follower_id, "loyalty", 5, "follow_user", {
|
||
following_id,
|
||
});
|
||
const { data: follower } = await adminSupabase
|
||
.from("user_profiles")
|
||
.select("full_name, username")
|
||
.eq("id", follower_id)
|
||
.maybeSingle();
|
||
const followerName =
|
||
(follower as any)?.full_name ||
|
||
(follower as any)?.username ||
|
||
"Someone";
|
||
await adminSupabase.from("notifications").insert({
|
||
user_id: following_id,
|
||
type: "info",
|
||
title: "New follower",
|
||
message: `${followerName} started following you`,
|
||
});
|
||
return res.json({ ok: true });
|
||
} catch (e: any) {
|
||
return res.status(500).json({ error: e?.message || String(e) });
|
||
}
|
||
});
|
||
|
||
app.post("/api/social/unfollow", async (req, res) => {
|
||
const { follower_id, following_id } = (req.body || {}) as {
|
||
follower_id?: string;
|
||
following_id?: string;
|
||
};
|
||
if (!follower_id || !following_id)
|
||
return res
|
||
.status(400)
|
||
.json({ error: "follower_id and following_id required" });
|
||
try {
|
||
await adminSupabase
|
||
.from("user_follows")
|
||
.delete()
|
||
.eq("follower_id", follower_id)
|
||
.eq("following_id", following_id);
|
||
return res.json({ ok: true });
|
||
} catch (e: any) {
|
||
return res.status(500).json({ error: e?.message || String(e) });
|
||
}
|
||
});
|
||
|
||
// Get following list
|
||
app.get("/api/social/following", async (req, res) => {
|
||
const userId = req.query.userId as string;
|
||
if (!userId) {
|
||
return res
|
||
.status(400)
|
||
.json({ error: "userId query parameter required" });
|
||
}
|
||
try {
|
||
const { data, error } = await adminSupabase
|
||
.from("user_follows")
|
||
.select("following_id")
|
||
.eq("follower_id", userId);
|
||
|
||
if (error) {
|
||
return res
|
||
.status(500)
|
||
.json({ error: "Failed to fetch following list" });
|
||
}
|
||
|
||
return res.json({
|
||
data: (data || []).map((r: any) => r.following_id),
|
||
});
|
||
} catch (e: any) {
|
||
return res.status(500).json({ error: e?.message || String(e) });
|
||
}
|
||
});
|
||
|
||
// Get followers list
|
||
app.get("/api/social/followers", async (req, res) => {
|
||
const userId = req.query.userId as string;
|
||
if (!userId) {
|
||
return res
|
||
.status(400)
|
||
.json({ error: "userId query parameter required" });
|
||
}
|
||
try {
|
||
const { data, error } = await adminSupabase
|
||
.from("user_follows")
|
||
.select("follower_id")
|
||
.eq("following_id", userId);
|
||
|
||
if (error) {
|
||
return res
|
||
.status(500)
|
||
.json({ error: "Failed to fetch followers list" });
|
||
}
|
||
|
||
return res.json({
|
||
data: (data || []).map((r: any) => r.follower_id),
|
||
});
|
||
} catch (e: any) {
|
||
return res.status(500).json({ error: e?.message || String(e) });
|
||
}
|
||
});
|
||
|
||
// Community post likes
|
||
app.post("/api/community/posts/:id/like", async (req, res) => {
|
||
const postId = req.params.id;
|
||
const { user_id } = (req.body || {}) as { user_id?: string };
|
||
if (!postId || !user_id)
|
||
return res.status(400).json({ error: "post id and user_id required" });
|
||
try {
|
||
// Get post author info before liking
|
||
const { data: post } = await adminSupabase
|
||
.from("community_posts")
|
||
.select("user_id")
|
||
.eq("id", postId)
|
||
.single();
|
||
|
||
const { error: likeErr } = await adminSupabase
|
||
.from("community_post_likes")
|
||
.upsert({ post_id: postId, user_id } as any, {
|
||
onConflict: "post_id,user_id" as any,
|
||
});
|
||
if (likeErr) return res.status(500).json({ error: likeErr.message });
|
||
const { data: c } = await adminSupabase
|
||
.from("community_post_likes")
|
||
.select("post_id", { count: "exact", head: true })
|
||
.eq("post_id", postId);
|
||
const count = (c as any)?.length
|
||
? (c as any).length
|
||
: (c as any)?.count || null;
|
||
if (typeof count === "number") {
|
||
await adminSupabase
|
||
.from("community_posts")
|
||
.update({ likes_count: count })
|
||
.eq("id", postId);
|
||
}
|
||
|
||
// Notify post author of like (only if different user)
|
||
if (post?.user_id && post.user_id !== user_id) {
|
||
try {
|
||
const { data: liker } = await adminSupabase
|
||
.from("user_profiles")
|
||
.select("full_name, username")
|
||
.eq("id", user_id)
|
||
.single();
|
||
|
||
const likerName =
|
||
(liker as any)?.full_name ||
|
||
(liker as any)?.username ||
|
||
"Someone";
|
||
await adminSupabase.from("notifications").insert({
|
||
user_id: post.user_id,
|
||
type: "info",
|
||
title: "❤️ Your post was liked",
|
||
message: `${likerName} liked your post.`,
|
||
});
|
||
} catch (notifError) {
|
||
console.warn("Failed to create like notification:", notifError);
|
||
}
|
||
}
|
||
|
||
return res.json({
|
||
ok: true,
|
||
likes: typeof count === "number" ? count : undefined,
|
||
});
|
||
} catch (e: any) {
|
||
return res.status(500).json({ error: e?.message || String(e) });
|
||
}
|
||
});
|
||
|
||
app.post("/api/community/posts/:id/unlike", async (req, res) => {
|
||
const postId = req.params.id;
|
||
const { user_id } = (req.body || {}) as { user_id?: string };
|
||
if (!postId || !user_id)
|
||
return res.status(400).json({ error: "post id and user_id required" });
|
||
try {
|
||
await adminSupabase
|
||
.from("community_post_likes")
|
||
.delete()
|
||
.eq("post_id", postId)
|
||
.eq("user_id", user_id);
|
||
const { data: c } = await adminSupabase
|
||
.from("community_post_likes")
|
||
.select("post_id", { count: "exact", head: true })
|
||
.eq("post_id", postId);
|
||
const count = (c as any)?.length
|
||
? (c as any).length
|
||
: (c as any)?.count || null;
|
||
if (typeof count === "number") {
|
||
await adminSupabase
|
||
.from("community_posts")
|
||
.update({ likes_count: count })
|
||
.eq("id", postId);
|
||
}
|
||
return res.json({
|
||
ok: true,
|
||
likes: typeof count === "number" ? count : undefined,
|
||
});
|
||
} catch (e: any) {
|
||
return res.status(500).json({ error: e?.message || String(e) });
|
||
}
|
||
});
|
||
|
||
// Community post comments
|
||
app.get("/api/community/posts/:id/comments", async (req, res) => {
|
||
const postId = req.params.id;
|
||
try {
|
||
const { data, error } = await adminSupabase
|
||
.from("community_comments")
|
||
.select(
|
||
"*, user_profiles:user_id ( id, full_name, username, avatar_url )",
|
||
)
|
||
.eq("post_id", postId)
|
||
.order("created_at", { ascending: true });
|
||
if (error) return res.status(500).json({ error: error.message });
|
||
res.json(data || []);
|
||
} catch (e: any) {
|
||
res.status(500).json({ error: e?.message || String(e) });
|
||
}
|
||
});
|
||
|
||
app.post("/api/community/posts/:id/comments", async (req, res) => {
|
||
const postId = req.params.id;
|
||
const { user_id, content } = (req.body || {}) as {
|
||
user_id?: string;
|
||
content?: string;
|
||
};
|
||
if (!user_id || !content)
|
||
return res.status(400).json({ error: "user_id and content required" });
|
||
try {
|
||
// Get post author info
|
||
const { data: post } = await adminSupabase
|
||
.from("community_posts")
|
||
.select("user_id")
|
||
.eq("id", postId)
|
||
.single();
|
||
|
||
const { data, error } = await adminSupabase
|
||
.from("community_comments")
|
||
.insert({ post_id: postId, user_id, content } as any)
|
||
.select()
|
||
.single();
|
||
if (error) return res.status(500).json({ error: error.message });
|
||
const { data: agg } = await adminSupabase
|
||
.from("community_comments")
|
||
.select("post_id", { count: "exact", head: true })
|
||
.eq("post_id", postId);
|
||
const count = (agg as any)?.count || null;
|
||
if (typeof count === "number") {
|
||
await adminSupabase
|
||
.from("community_posts")
|
||
.update({ comments_count: count })
|
||
.eq("id", postId);
|
||
}
|
||
|
||
// Notify post author of comment (only if different user)
|
||
if (post?.user_id && post.user_id !== user_id) {
|
||
try {
|
||
const { data: commenter } = await adminSupabase
|
||
.from("user_profiles")
|
||
.select("full_name, username")
|
||
.eq("id", user_id)
|
||
.single();
|
||
|
||
const commenterName =
|
||
(commenter as any)?.full_name ||
|
||
(commenter as any)?.username ||
|
||
"Someone";
|
||
const preview =
|
||
content.substring(0, 50) + (content.length > 50 ? "..." : "");
|
||
await adminSupabase.from("notifications").insert({
|
||
user_id: post.user_id,
|
||
type: "info",
|
||
title: "💬 New comment on your post",
|
||
message: `${commenterName} commented: "${preview}"`,
|
||
});
|
||
} catch (notifError) {
|
||
console.warn("Failed to create comment notification:", notifError);
|
||
}
|
||
}
|
||
|
||
res.json(data);
|
||
} catch (e: any) {
|
||
res.status(500).json({ error: e?.message || String(e) });
|
||
}
|
||
});
|
||
|
||
// Endorse with notification
|
||
app.post("/api/social/endorse", async (req, res) => {
|
||
const { endorser_id, endorsed_id, skill } = (req.body || {}) as {
|
||
endorser_id?: string;
|
||
endorsed_id?: string;
|
||
skill?: string;
|
||
};
|
||
if (!endorser_id || !endorsed_id || !skill)
|
||
return res
|
||
.status(400)
|
||
.json({ error: "endorser_id, endorsed_id, skill required" });
|
||
try {
|
||
await adminSupabase
|
||
.from("endorsements")
|
||
.insert({ endorser_id, endorsed_id, skill } as any);
|
||
await accrue(endorsed_id, "reputation", 2, "endorsement_received", {
|
||
skill,
|
||
from: endorser_id,
|
||
});
|
||
const { data: endorser } = await adminSupabase
|
||
.from("user_profiles")
|
||
.select("full_name, username")
|
||
.eq("id", endorser_id)
|
||
.maybeSingle();
|
||
const endorserName =
|
||
(endorser as any)?.full_name ||
|
||
(endorser as any)?.username ||
|
||
"Someone";
|
||
await adminSupabase.from("notifications").insert({
|
||
user_id: endorsed_id,
|
||
type: "success",
|
||
title: "New endorsement",
|
||
message: `${endorserName} endorsed you for ${skill}`,
|
||
});
|
||
return res.json({ ok: true });
|
||
} catch (e: any) {
|
||
return res.status(500).json({ error: e?.message || String(e) });
|
||
}
|
||
});
|
||
|
||
// Activity bus: publish event and fanout notifications
|
||
app.post("/api/activity/publish", async (req, res) => {
|
||
const {
|
||
actor_id,
|
||
verb,
|
||
object_type,
|
||
object_id,
|
||
target_user_ids,
|
||
target_team_id,
|
||
target_project_id,
|
||
metadata,
|
||
} = (req.body || {}) as any;
|
||
if (!actor_id || !verb || !object_type) {
|
||
return res
|
||
.status(400)
|
||
.json({ error: "actor_id, verb, object_type required" });
|
||
}
|
||
try {
|
||
const { data: eventRow, error: evErr } = await adminSupabase
|
||
.from("activity_events")
|
||
.insert({
|
||
actor_id,
|
||
verb,
|
||
object_type,
|
||
object_id: object_id || null,
|
||
target_id: target_team_id || target_project_id || null,
|
||
metadata: metadata || null,
|
||
} as any)
|
||
.select()
|
||
.single();
|
||
if (evErr) return res.status(500).json({ error: evErr.message });
|
||
|
||
const notify = async (
|
||
userId: string,
|
||
title: string,
|
||
message?: string,
|
||
) => {
|
||
await adminSupabase.from("notifications").insert({
|
||
user_id: userId,
|
||
type: "info",
|
||
title,
|
||
message: message || null,
|
||
});
|
||
};
|
||
|
||
// Notify explicit targets
|
||
if (Array.isArray(target_user_ids) && target_user_ids.length) {
|
||
for (const uid of target_user_ids) {
|
||
await notify(
|
||
uid,
|
||
`${verb} · ${object_type}`,
|
||
(metadata && metadata.summary) || null,
|
||
);
|
||
}
|
||
}
|
||
|
||
// Notify team members if provided
|
||
if (target_team_id) {
|
||
const { data: members } = await adminSupabase
|
||
.from("team_memberships")
|
||
.select("user_id")
|
||
.eq("team_id", target_team_id);
|
||
for (const m of members || []) {
|
||
await notify(
|
||
(m as any).user_id,
|
||
`${verb} · ${object_type}`,
|
||
(metadata && metadata.summary) || null,
|
||
);
|
||
}
|
||
}
|
||
|
||
// Notify project members if provided
|
||
if (target_project_id) {
|
||
const { data: members } = await adminSupabase
|
||
.from("project_members")
|
||
.select("user_id")
|
||
.eq("project_id", target_project_id);
|
||
for (const m of members || []) {
|
||
await notify(
|
||
(m as any).user_id,
|
||
`${verb} · ${object_type}`,
|
||
(metadata && metadata.summary) || null,
|
||
);
|
||
}
|
||
}
|
||
|
||
return res.json({ ok: true, event: eventRow });
|
||
} catch (e: any) {
|
||
return res.status(500).json({ error: e?.message || String(e) });
|
||
}
|
||
});
|
||
|
||
app.post("/api/rewards/apply", async (req, res) => {
|
||
const { user_id, action, amount } = (req.body || {}) as {
|
||
user_id?: string;
|
||
action?: string;
|
||
amount?: number | null;
|
||
};
|
||
if (!user_id || !action) {
|
||
return res.status(400).json({ error: "user_id and action required" });
|
||
}
|
||
try {
|
||
const actionKey = String(action);
|
||
switch (actionKey) {
|
||
case "post_created":
|
||
await accrue(user_id, "xp", amount ?? 25, actionKey);
|
||
await accrue(user_id, "loyalty", 5, actionKey);
|
||
break;
|
||
case "follow_user":
|
||
await accrue(user_id, "loyalty", 5, actionKey);
|
||
break;
|
||
case "endorsement_received":
|
||
await accrue(user_id, "reputation", amount ?? 2, actionKey);
|
||
break;
|
||
case "daily_login":
|
||
await accrue(user_id, "xp", amount ?? 10, actionKey);
|
||
await accrue(user_id, "loyalty", 2, actionKey);
|
||
break;
|
||
default:
|
||
await accrue(user_id, "xp", Math.max(0, amount ?? 0), actionKey);
|
||
break;
|
||
}
|
||
return res.json({ ok: true });
|
||
} catch (e: any) {
|
||
return res.status(500).json({ error: e?.message || String(e) });
|
||
}
|
||
});
|
||
|
||
// Investors: capture interest
|
||
app.post("/api/investors/interest", async (req, res) => {
|
||
const { name, email, amount, accredited, message } = (req.body || {}) as {
|
||
name?: string;
|
||
email?: string;
|
||
amount?: string;
|
||
accredited?: boolean;
|
||
message?: string;
|
||
};
|
||
if (!email) return res.status(400).json({ error: "email required" });
|
||
try {
|
||
const subject = `Investor interest: ${name || email}`;
|
||
const body = [
|
||
`Name: ${name || "N/A"}`,
|
||
`Email: ${email}`,
|
||
`Amount: ${amount || "N/A"}`,
|
||
`Accredited: ${accredited ? "Yes" : "No / Unknown"}`,
|
||
message ? `\nMessage:\n${message}` : "",
|
||
].join("\n");
|
||
try {
|
||
const { emailService } = await import("./email");
|
||
if (emailService.isConfigured) {
|
||
await (emailService as any).sendInviteEmail({
|
||
to: process.env.VERIFY_SUPPORT_EMAIL || "support@aethex.biz",
|
||
inviteUrl: "https://aethex.dev/investors",
|
||
inviterName: name || email,
|
||
message: body,
|
||
});
|
||
}
|
||
} catch (e) {
|
||
/* ignore email errors to not block */
|
||
}
|
||
return res.json({ ok: true });
|
||
} catch (e: any) {
|
||
return res.status(500).json({ error: e?.message || String(e) });
|
||
}
|
||
});
|
||
|
||
// Leads: capture website leads (Wix microsite and others)
|
||
app.post("/api/leads", async (req, res) => {
|
||
const {
|
||
name,
|
||
email,
|
||
company,
|
||
website,
|
||
budget,
|
||
timeline,
|
||
message,
|
||
source,
|
||
} = (req.body || {}) as {
|
||
name?: string;
|
||
email?: string;
|
||
company?: string;
|
||
website?: string;
|
||
budget?: string;
|
||
timeline?: string;
|
||
message?: string;
|
||
source?: string;
|
||
};
|
||
if (!email) return res.status(400).json({ error: "email required" });
|
||
try {
|
||
const lines = [
|
||
`Source: ${source || "web"}`,
|
||
`Name: ${name || "N/A"}`,
|
||
`Email: ${email}`,
|
||
`Company: ${company || "N/A"}`,
|
||
`Website: ${website || "N/A"}`,
|
||
`Budget: ${budget || "N/A"}`,
|
||
`Timeline: ${timeline || "N/A"}`,
|
||
message ? `\nMessage:\n${message}` : "",
|
||
].join("\n");
|
||
|
||
try {
|
||
if (emailService.isConfigured) {
|
||
const base =
|
||
process.env.PUBLIC_BASE_URL ||
|
||
process.env.SITE_URL ||
|
||
"https://aethex.dev";
|
||
await (emailService as any).sendInviteEmail({
|
||
to: process.env.VERIFY_SUPPORT_EMAIL || "support@aethex.biz",
|
||
inviteUrl: `${base}/wix`,
|
||
inviterName: name || email,
|
||
message: lines,
|
||
});
|
||
}
|
||
} catch (e) {
|
||
// continue even if email fails
|
||
}
|
||
|
||
return res.json({ ok: true });
|
||
} catch (e: any) {
|
||
return res.status(500).json({ error: e?.message || String(e) });
|
||
}
|
||
});
|
||
|
||
// Roblox inbound callback (from HttpService or external automation)
|
||
app.get("/roblox-callback", (_req, res) => res.json({ ok: true }));
|
||
app.post("/roblox-callback", async (req, res) => {
|
||
const shared =
|
||
process.env.ROBLOX_SHARED_SECRET ||
|
||
process.env.ROBLOX_WEBHOOK_SECRET ||
|
||
"";
|
||
const sig = String(
|
||
req.get("x-shared-secret") || req.get("x-roblox-signature") || "",
|
||
);
|
||
if (shared && sig !== shared)
|
||
return res.status(401).json({ error: "unauthorized" });
|
||
try {
|
||
const payload = {
|
||
...((req.body as any) || {}),
|
||
ip: (req.headers["x-forwarded-for"] as string) || req.ip,
|
||
ua: req.get("user-agent") || null,
|
||
received_at: new Date().toISOString(),
|
||
};
|
||
// Best-effort persist if table exists
|
||
try {
|
||
await adminSupabase.from("roblox_events").insert({
|
||
event_type: (payload as any).event || null,
|
||
payload,
|
||
} as any);
|
||
} catch (e: any) {
|
||
// ignore if table missing or RLS blocks
|
||
}
|
||
return res.json({ ok: true });
|
||
} catch (e: any) {
|
||
return res.status(500).json({ error: e?.message || String(e) });
|
||
}
|
||
});
|
||
|
||
// Staff: users search/listing
|
||
app.get("/api/staff/users", async (req, res) => {
|
||
const limit = Math.max(1, Math.min(50, Number(req.query.limit) || 20));
|
||
const q = String(req.query.q || "")
|
||
.trim()
|
||
.toLowerCase();
|
||
try {
|
||
const { data, error } = await adminSupabase
|
||
.from("user_profiles")
|
||
.select(
|
||
"id, username, full_name, avatar_url, user_type, created_at, updated_at",
|
||
)
|
||
.order("created_at", { ascending: false })
|
||
.limit(limit);
|
||
if (error) return res.status(500).json({ error: error.message });
|
||
let rows = (data || []) as any[];
|
||
if (q) {
|
||
rows = rows.filter((r) => {
|
||
const name = String(r.full_name || r.username || "").toLowerCase();
|
||
return name.includes(q);
|
||
});
|
||
}
|
||
return res.json(rows);
|
||
} catch (e: any) {
|
||
return res.status(500).json({ error: e?.message || String(e) });
|
||
}
|
||
});
|
||
|
||
// Mentorship API
|
||
app.get("/api/mentors", async (req, res) => {
|
||
const limit = Math.max(1, Math.min(50, Number(req.query.limit) || 20));
|
||
const available =
|
||
String(req.query.available || "true").toLowerCase() !== "false";
|
||
const expertise = String(req.query.expertise || "").trim();
|
||
const q = String(req.query.q || "")
|
||
.trim()
|
||
.toLowerCase();
|
||
try {
|
||
const { data, error } = await adminSupabase
|
||
.from("mentors")
|
||
.select(
|
||
`user_id, bio, expertise, available, hourly_rate, created_at, updated_at, user_profiles:user_id ( id, username, full_name, avatar_url, bio )`,
|
||
)
|
||
.eq("available", available)
|
||
.order("updated_at", { ascending: false })
|
||
.limit(limit);
|
||
if (error) {
|
||
if (isTableMissing(error)) return res.json([]);
|
||
return res.status(500).json({ error: error.message });
|
||
}
|
||
let rows = (data || []) as any[];
|
||
if (expertise) {
|
||
const terms = expertise
|
||
.split(",")
|
||
.map((s) => s.trim().toLowerCase())
|
||
.filter(Boolean);
|
||
if (terms.length) {
|
||
rows = rows.filter(
|
||
(r: any) =>
|
||
Array.isArray(r.expertise) &&
|
||
r.expertise.some((e: string) =>
|
||
terms.includes(String(e).toLowerCase()),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
if (q) {
|
||
rows = rows.filter((r: any) => {
|
||
const up = (r as any).user_profiles || {};
|
||
const name = String(
|
||
up.full_name || up.username || "",
|
||
).toLowerCase();
|
||
const bio = String(r.bio || up.bio || "").toLowerCase();
|
||
return name.includes(q) || bio.includes(q);
|
||
});
|
||
}
|
||
return res.json(rows);
|
||
} catch (e: any) {
|
||
return res.status(500).json({ error: e?.message || String(e) });
|
||
}
|
||
});
|
||
|
||
// System Status
|
||
app.get("/api/status", async (req, res) => {
|
||
const startedAt = Date.now();
|
||
const host = `${req.protocol}://${req.get("host")}`;
|
||
|
||
const time = async (fn: () => Promise<any>) => {
|
||
const t0 = Date.now();
|
||
try {
|
||
await fn();
|
||
return { ok: true, ms: Date.now() - t0 };
|
||
} catch (e) {
|
||
return {
|
||
ok: false,
|
||
ms: Date.now() - t0,
|
||
error: (e as any)?.message || String(e),
|
||
};
|
||
}
|
||
};
|
||
|
||
// Database check (user_profiles)
|
||
const dbCheck = await time(async () => {
|
||
await adminSupabase
|
||
.from("user_profiles")
|
||
.select("id", { head: true, count: "exact" })
|
||
.limit(1);
|
||
});
|
||
|
||
// API/Core check (community_posts)
|
||
const apiCheck = await time(async () => {
|
||
await adminSupabase
|
||
.from("community_posts")
|
||
.select("id", { head: true, count: "exact" })
|
||
.limit(1);
|
||
});
|
||
|
||
// Auth check
|
||
const authCheck = await time(async () => {
|
||
const admin = (adminSupabase as any)?.auth?.admin;
|
||
if (!admin) throw new Error("auth admin unavailable");
|
||
await admin.listUsers({ page: 1, perPage: 1 } as any);
|
||
});
|
||
|
||
// CDN/static
|
||
const cdnCheck = await time(async () => {
|
||
const resp = await fetch(`${host}/robots.txt`).catch(() => null);
|
||
if (!resp || !resp.ok) throw new Error("robots not reachable");
|
||
});
|
||
|
||
const statusFrom = (c: { ok: boolean; ms: number }) =>
|
||
!c.ok ? "outage" : c.ms > 800 ? "degraded" : "operational";
|
||
|
||
const nowIso = new Date().toISOString();
|
||
const services = [
|
||
{
|
||
name: "AeThex Core API",
|
||
status: statusFrom(apiCheck) as any,
|
||
responseTime: apiCheck.ms,
|
||
uptime: apiCheck.ok ? "99.99%" : "--",
|
||
lastCheck: nowIso,
|
||
description: "Main application API and endpoints",
|
||
},
|
||
{
|
||
name: "Database Services",
|
||
status: statusFrom(dbCheck) as any,
|
||
responseTime: dbCheck.ms,
|
||
uptime: dbCheck.ok ? "99.99%" : "--",
|
||
lastCheck: nowIso,
|
||
description: "Supabase Postgres and Storage",
|
||
},
|
||
{
|
||
name: "CDN & Assets",
|
||
status: statusFrom(cdnCheck) as any,
|
||
responseTime: cdnCheck.ms,
|
||
uptime: cdnCheck.ok ? "99.95%" : "--",
|
||
lastCheck: nowIso,
|
||
description: "Static and media delivery",
|
||
},
|
||
{
|
||
name: "Authentication",
|
||
status: statusFrom(authCheck) as any,
|
||
responseTime: authCheck.ms,
|
||
uptime: authCheck.ok ? "99.97%" : "--",
|
||
lastCheck: nowIso,
|
||
description: "OAuth and email auth (Supabase)",
|
||
},
|
||
];
|
||
|
||
const avgRt = Math.round(
|
||
services.reduce((a, s) => a + (Number(s.responseTime) || 0), 0) /
|
||
services.length,
|
||
);
|
||
const errCount = services.filter((s) => s.status === "outage").length;
|
||
const warnCount = services.filter((s) => s.status === "degraded").length;
|
||
|
||
// Active users (best effort)
|
||
let activeUsers = "--";
|
||
try {
|
||
const { count } = await adminSupabase
|
||
.from("user_profiles")
|
||
.select("id", { head: true, count: "exact" });
|
||
if (typeof count === "number") activeUsers = count.toLocaleString();
|
||
} catch {}
|
||
|
||
const metrics = [
|
||
{
|
||
name: "Global Uptime",
|
||
value: errCount ? "99.5" : warnCount ? "99.9" : "99.99",
|
||
unit: "%",
|
||
status: errCount ? "critical" : warnCount ? "warning" : "good",
|
||
icon: "Activity",
|
||
},
|
||
{
|
||
name: "Response Time",
|
||
value: String(avgRt),
|
||
unit: "ms",
|
||
status: avgRt > 800 ? "critical" : avgRt > 400 ? "warning" : "good",
|
||
icon: "Zap",
|
||
},
|
||
{
|
||
name: "Active Users",
|
||
value: activeUsers,
|
||
unit: "",
|
||
status: "good",
|
||
icon: "Globe",
|
||
},
|
||
{
|
||
name: "Error Rate",
|
||
value: String(errCount),
|
||
unit: " outages",
|
||
status: errCount ? "critical" : warnCount ? "warning" : "good",
|
||
icon: "Shield",
|
||
},
|
||
];
|
||
|
||
res.json({
|
||
updatedAt: new Date().toISOString(),
|
||
durationMs: Date.now() - startedAt,
|
||
services,
|
||
metrics,
|
||
incidents: [],
|
||
});
|
||
});
|
||
|
||
app.post("/api/mentors/apply", async (req, res) => {
|
||
const { user_id, bio, expertise, hourly_rate, available } = (req.body ||
|
||
{}) as {
|
||
user_id?: string;
|
||
bio?: string | null;
|
||
expertise?: string[];
|
||
hourly_rate?: number | null;
|
||
available?: boolean | null;
|
||
};
|
||
if (!user_id) return res.status(400).json({ error: "user_id required" });
|
||
try {
|
||
const payload: any = {
|
||
user_id,
|
||
bio: bio ?? null,
|
||
expertise: Array.isArray(expertise) ? expertise : [],
|
||
available: available ?? true,
|
||
hourly_rate: typeof hourly_rate === "number" ? hourly_rate : null,
|
||
};
|
||
const { data, error } = await adminSupabase
|
||
.from("mentors")
|
||
.upsert(payload, { onConflict: "user_id" as any })
|
||
.select()
|
||
.single();
|
||
if (error) return res.status(500).json({ error: error.message });
|
||
|
||
try {
|
||
await adminSupabase.from("notifications").insert({
|
||
user_id,
|
||
type: "success",
|
||
title: "Mentor profile updated",
|
||
message: "Your mentor availability and expertise are saved.",
|
||
});
|
||
} catch {}
|
||
|
||
return res.json(data || payload);
|
||
} catch (e: any) {
|
||
return res.status(500).json({ error: e?.message || String(e) });
|
||
}
|
||
});
|
||
|
||
app.post("/api/mentorship/request", async (req, res) => {
|
||
const { mentee_id, mentor_id, message } = (req.body || {}) as {
|
||
mentee_id?: string;
|
||
mentor_id?: string;
|
||
message?: string | null;
|
||
};
|
||
if (!mentee_id || !mentor_id) {
|
||
return res
|
||
.status(400)
|
||
.json({ error: "mentee_id and mentor_id required" });
|
||
}
|
||
if (mentee_id === mentor_id) {
|
||
return res.status(400).json({ error: "cannot request yourself" });
|
||
}
|
||
try {
|
||
const { data, error } = await adminSupabase
|
||
.from("mentorship_requests")
|
||
.insert({ mentee_id, mentor_id, message: message || null } as any)
|
||
.select()
|
||
.single();
|
||
if (error) return res.status(500).json({ error: error.message });
|
||
|
||
try {
|
||
const { data: mentee } = await adminSupabase
|
||
.from("user_profiles")
|
||
.select("full_name, username")
|
||
.eq("id", mentee_id)
|
||
.maybeSingle();
|
||
const menteeName =
|
||
(mentee as any)?.full_name ||
|
||
(mentee as any)?.username ||
|
||
"Someone";
|
||
await adminSupabase.from("notifications").insert({
|
||
user_id: mentor_id,
|
||
type: "info",
|
||
title: "Mentorship request",
|
||
message: `${menteeName} requested mentorship.`,
|
||
});
|
||
} catch {}
|
||
|
||
return res.json({ ok: true, request: data });
|
||
} catch (e: any) {
|
||
return res.status(500).json({ error: e?.message || String(e) });
|
||
}
|
||
});
|
||
|
||
app.post("/api/mentorship/requests/:id/status", async (req, res) => {
|
||
const id = String(req.params.id || "");
|
||
const { actor_id, status } = (req.body || {}) as {
|
||
actor_id?: string;
|
||
status?: string;
|
||
};
|
||
if (!id || !actor_id || !status) {
|
||
return res.status(400).json({ error: "id, actor_id, status required" });
|
||
}
|
||
const allowed = ["accepted", "rejected", "cancelled"];
|
||
if (!allowed.includes(String(status))) {
|
||
return res.status(400).json({ error: "invalid status" });
|
||
}
|
||
try {
|
||
const { data: reqRow, error } = await adminSupabase
|
||
.from("mentorship_requests")
|
||
.select("id, mentor_id, mentee_id, status")
|
||
.eq("id", id)
|
||
.maybeSingle();
|
||
if (error) return res.status(500).json({ error: error.message });
|
||
if (!reqRow) return res.status(404).json({ error: "not_found" });
|
||
|
||
const isMentor = (reqRow as any).mentor_id === actor_id;
|
||
const isMentee = (reqRow as any).mentee_id === actor_id;
|
||
if ((status === "accepted" || status === "rejected") && !isMentor) {
|
||
return res.status(403).json({ error: "forbidden" });
|
||
}
|
||
if (status === "cancelled" && !isMentee) {
|
||
return res.status(403).json({ error: "forbidden" });
|
||
}
|
||
|
||
const { data, error: upErr } = await adminSupabase
|
||
.from("mentorship_requests")
|
||
.update({ status })
|
||
.eq("id", id)
|
||
.select()
|
||
.single();
|
||
if (upErr) return res.status(500).json({ error: upErr.message });
|
||
|
||
try {
|
||
const target =
|
||
status === "cancelled"
|
||
? (reqRow as any).mentor_id
|
||
: (reqRow as any).mentee_id;
|
||
const title =
|
||
status === "accepted"
|
||
? "Mentorship accepted"
|
||
: status === "rejected"
|
||
? "Mentorship rejected"
|
||
: "Mentorship cancelled";
|
||
await adminSupabase.from("notifications").insert({
|
||
user_id: target,
|
||
type: "info",
|
||
title,
|
||
message: null,
|
||
});
|
||
} catch {}
|
||
|
||
return res.json({ ok: true, request: data });
|
||
} catch (e: any) {
|
||
return res.status(500).json({ error: e?.message || String(e) });
|
||
}
|
||
});
|
||
|
||
app.get("/api/mentorship/requests", async (req, res) => {
|
||
const userId = String(req.query.user_id || "");
|
||
const role = String(req.query.role || "").toLowerCase();
|
||
if (!userId) return res.status(400).json({ error: "user_id required" });
|
||
try {
|
||
let query = adminSupabase
|
||
.from("mentorship_requests")
|
||
.select(
|
||
`*, mentor:user_profiles!mentorship_requests_mentor_id_fkey ( id, full_name, username, avatar_url ), mentee:user_profiles!mentorship_requests_mentee_id_fkey ( id, full_name, username, avatar_url )`,
|
||
)
|
||
.order("created_at", { ascending: false });
|
||
if (role === "mentor") query = query.eq("mentor_id", userId);
|
||
else if (role === "mentee") query = query.eq("mentee_id", userId);
|
||
else query = query.or(`mentor_id.eq.${userId},mentee_id.eq.${userId}`);
|
||
const { data, error } = await query;
|
||
if (error) {
|
||
if (isTableMissing(error)) return res.json([]);
|
||
return res.status(500).json({ error: error.message });
|
||
}
|
||
return res.json(data || []);
|
||
} catch (e: any) {
|
||
return res.status(500).json({ error: e?.message || String(e) });
|
||
}
|
||
});
|
||
|
||
// Staff: list all mentorship requests (limited)
|
||
app.get("/api/mentorship/requests/all", async (req, res) => {
|
||
const limit = Math.max(1, Math.min(200, Number(req.query.limit) || 50));
|
||
const status = String(req.query.status || "").toLowerCase();
|
||
try {
|
||
let query = adminSupabase
|
||
.from("mentorship_requests")
|
||
.select(
|
||
`*, mentor:user_profiles!mentorship_requests_mentor_id_fkey ( id, full_name, username, avatar_url ), mentee:user_profiles!mentorship_requests_mentee_id_fkey ( id, full_name, username, avatar_url )`,
|
||
)
|
||
.order("created_at", { ascending: false })
|
||
.limit(limit);
|
||
if (status) query = query.eq("status", status);
|
||
const { data, error } = await query;
|
||
if (error) return res.status(500).json({ error: error.message });
|
||
return res.json(data || []);
|
||
} catch (e: any) {
|
||
return res.status(500).json({ error: e?.message || String(e) });
|
||
}
|
||
});
|
||
|
||
// Moderation API
|
||
app.post("/api/moderation/reports", async (req, res) => {
|
||
const { reporter_id, target_type, target_id, reason, details } =
|
||
(req.body || {}) as any;
|
||
if (!target_type || !reason) {
|
||
return res
|
||
.status(400)
|
||
.json({ error: "target_type and reason required" });
|
||
}
|
||
try {
|
||
const { data, error } = await adminSupabase
|
||
.from("moderation_reports")
|
||
.insert({
|
||
reporter_id: reporter_id || null,
|
||
target_type,
|
||
target_id: target_id || null,
|
||
reason,
|
||
details: details || null,
|
||
} as any)
|
||
.select()
|
||
.single();
|
||
if (error) return res.status(500).json({ error: error.message });
|
||
return res.json(data);
|
||
} catch (e: any) {
|
||
return res.status(500).json({ error: e?.message || String(e) });
|
||
}
|
||
});
|
||
|
||
app.get("/api/moderation/reports", async (req, res) => {
|
||
const status = String(req.query.status || "open").toLowerCase();
|
||
const limit = Math.max(1, Math.min(200, Number(req.query.limit) || 50));
|
||
try {
|
||
const { data, error } = await adminSupabase
|
||
.from("moderation_reports")
|
||
.select(
|
||
`*, reporter:user_profiles!moderation_reports_reporter_id_fkey ( id, full_name, username, avatar_url )`,
|
||
)
|
||
.eq("status", status)
|
||
.order("created_at", { ascending: false })
|
||
.limit(limit);
|
||
if (error) return res.status(500).json({ error: error.message });
|
||
return res.json(data || []);
|
||
} catch (e: any) {
|
||
return res.status(500).json({ error: e?.message || String(e) });
|
||
}
|
||
});
|
||
|
||
app.post("/api/moderation/reports/:id/status", async (req, res) => {
|
||
const id = String(req.params.id || "");
|
||
const { status } = (req.body || {}) as { status?: string };
|
||
const allowed = ["open", "resolved", "ignored"];
|
||
if (!id || !status || !allowed.includes(String(status))) {
|
||
return res.status(400).json({ error: "invalid input" });
|
||
}
|
||
try {
|
||
const { data, error } = await adminSupabase
|
||
.from("moderation_reports")
|
||
.update({ status })
|
||
.eq("id", id)
|
||
.select()
|
||
.single();
|
||
if (error) return res.status(500).json({ error: error.message });
|
||
return res.json(data);
|
||
} catch (e: any) {
|
||
return res.status(500).json({ error: e?.message || String(e) });
|
||
}
|
||
});
|
||
|
||
// Creator Network API Routes
|
||
|
||
// Get all creators with filters
|
||
app.get("/api/creators", async (req, res) => {
|
||
try {
|
||
const arm = String(req.query.arm || "").trim();
|
||
const page = Math.max(1, Number(req.query.page) || 1);
|
||
const limit = Math.max(1, Math.min(100, Number(req.query.limit) || 20));
|
||
const search = String(req.query.search || "").trim();
|
||
|
||
let query = adminSupabase
|
||
.from("aethex_creators")
|
||
.select(
|
||
`
|
||
id,
|
||
username,
|
||
bio,
|
||
skills,
|
||
avatar_url,
|
||
experience_level,
|
||
arm_affiliations,
|
||
primary_arm,
|
||
created_at
|
||
`,
|
||
{ count: "exact" },
|
||
)
|
||
.eq("is_discoverable", true)
|
||
.order("created_at", { ascending: false });
|
||
|
||
if (arm) {
|
||
query = query.contains("arm_affiliations", [arm]);
|
||
}
|
||
|
||
if (search) {
|
||
query = query.or(`username.ilike.%${search}%,bio.ilike.%${search}%`);
|
||
}
|
||
|
||
const start = (page - 1) * limit;
|
||
query = query.range(start, start + limit - 1);
|
||
|
||
const { data, error, count } = await query;
|
||
|
||
if (error) throw error;
|
||
|
||
return res.json({
|
||
data,
|
||
pagination: {
|
||
page,
|
||
limit,
|
||
total: count || 0,
|
||
pages: Math.ceil((count || 0) / limit),
|
||
},
|
||
});
|
||
} catch (e: any) {
|
||
console.error("[Creator API] Error fetching creators:", e?.message);
|
||
return res.status(500).json({ error: "Failed to fetch creators" });
|
||
}
|
||
});
|
||
|
||
// Get creator by username
|
||
app.get("/api/creators/:identifier", async (req, res) => {
|
||
try {
|
||
const identifier = String(req.params.identifier || "").trim();
|
||
if (!identifier) {
|
||
return res.status(400).json({ error: "identifier required" });
|
||
}
|
||
|
||
// Check if identifier is a UUID (username-first, UUID fallback)
|
||
const isUUID =
|
||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
|
||
identifier,
|
||
);
|
||
|
||
let query = adminSupabase
|
||
.from("aethex_creators")
|
||
.select(
|
||
`
|
||
id,
|
||
username,
|
||
bio,
|
||
skills,
|
||
avatar_url,
|
||
experience_level,
|
||
arm_affiliations,
|
||
primary_arm,
|
||
created_at,
|
||
updated_at
|
||
`,
|
||
)
|
||
.eq("is_discoverable", true);
|
||
|
||
// Try username first (preferred), then UUID fallback
|
||
if (isUUID) {
|
||
query = query.eq("id", identifier);
|
||
} else {
|
||
query = query.eq("username", identifier);
|
||
}
|
||
|
||
const { data: creator, error } = await query.single();
|
||
|
||
// If username lookup failed and it's a valid UUID format, try UUID
|
||
if (error && !isUUID) {
|
||
if (
|
||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
|
||
identifier,
|
||
)
|
||
) {
|
||
const { data: creatorByUUID, error: uuidError } =
|
||
await adminSupabase
|
||
.from("aethex_creators")
|
||
.select(
|
||
`
|
||
id,
|
||
username,
|
||
bio,
|
||
skills,
|
||
avatar_url,
|
||
experience_level,
|
||
arm_affiliations,
|
||
primary_arm,
|
||
created_at,
|
||
updated_at
|
||
`,
|
||
)
|
||
.eq("is_discoverable", true)
|
||
.eq("id", identifier)
|
||
.single();
|
||
|
||
if (!uuidError && creatorByUUID) {
|
||
// Found by UUID, optionally redirect to username canonical URL
|
||
const { data: devConnectLink } = await adminSupabase
|
||
.from("aethex_devconnect_links")
|
||
.select("devconnect_username, devconnect_profile_url")
|
||
.eq("aethex_creator_id", creatorByUUID.id)
|
||
.maybeSingle();
|
||
|
||
return res.json({
|
||
...creatorByUUID,
|
||
devconnect_link: devConnectLink,
|
||
});
|
||
}
|
||
}
|
||
|
||
if (error.code === "PGRST116") {
|
||
return res.status(404).json({ error: "Creator not found" });
|
||
}
|
||
throw error;
|
||
}
|
||
|
||
if (error) {
|
||
if (error.code === "PGRST116") {
|
||
return res.status(404).json({ error: "Creator not found" });
|
||
}
|
||
throw error;
|
||
}
|
||
|
||
const { data: devConnectLink } = await adminSupabase
|
||
.from("aethex_devconnect_links")
|
||
.select("devconnect_username, devconnect_profile_url")
|
||
.eq("aethex_creator_id", creator.id)
|
||
.maybeSingle();
|
||
|
||
return res.json({
|
||
...creator,
|
||
devconnect_link: devConnectLink,
|
||
});
|
||
} catch (e: any) {
|
||
console.error("[Creator API] Error fetching creator:", e?.message);
|
||
return res.status(500).json({ error: "Failed to fetch creator" });
|
||
}
|
||
});
|
||
|
||
// Get creator by user_id
|
||
app.get("/api/creators/user/:userId", async (req, res) => {
|
||
try {
|
||
const userId = String(req.params.userId || "").trim();
|
||
if (!userId) {
|
||
return res.status(400).json({ error: "userId required" });
|
||
}
|
||
|
||
const { data: creator, error } = await adminSupabase
|
||
.from("aethex_creators")
|
||
.select(
|
||
`
|
||
id,
|
||
user_id,
|
||
username,
|
||
bio,
|
||
skills,
|
||
avatar_url,
|
||
experience_level,
|
||
arm_affiliations,
|
||
primary_arm,
|
||
spotify_profile_url,
|
||
created_at,
|
||
updated_at
|
||
`,
|
||
)
|
||
.eq("user_id", userId)
|
||
.eq("is_discoverable", true)
|
||
.maybeSingle();
|
||
|
||
if (!creator) {
|
||
return res.status(404).json({ error: "Creator not found" });
|
||
}
|
||
|
||
if (error) throw error;
|
||
|
||
const { data: devConnectLink } = await adminSupabase
|
||
.from("aethex_devconnect_links")
|
||
.select("devconnect_username, devconnect_profile_url")
|
||
.eq("aethex_creator_id", creator.id)
|
||
.maybeSingle();
|
||
|
||
return res.json({
|
||
...creator,
|
||
devconnect_link: devConnectLink,
|
||
});
|
||
} catch (e: any) {
|
||
console.error(
|
||
"[Creator API] Error fetching creator by user_id:",
|
||
e?.message,
|
||
);
|
||
return res.status(500).json({ error: "Failed to fetch creator" });
|
||
}
|
||
});
|
||
|
||
// Create creator profile
|
||
app.post("/api/creators", async (req, res) => {
|
||
try {
|
||
const {
|
||
user_id,
|
||
username,
|
||
bio,
|
||
skills,
|
||
avatar_url,
|
||
experience_level,
|
||
primary_arm,
|
||
arm_affiliations,
|
||
} = req.body;
|
||
|
||
if (!user_id || !username) {
|
||
return res
|
||
.status(400)
|
||
.json({ error: "user_id and username required" });
|
||
}
|
||
|
||
const { data, error } = await adminSupabase
|
||
.from("aethex_creators")
|
||
.insert({
|
||
user_id,
|
||
username,
|
||
bio: bio || null,
|
||
skills: Array.isArray(skills) ? skills : [],
|
||
avatar_url: avatar_url || null,
|
||
experience_level: experience_level || null,
|
||
primary_arm: primary_arm || null,
|
||
arm_affiliations: Array.isArray(arm_affiliations)
|
||
? arm_affiliations
|
||
: [],
|
||
})
|
||
.select()
|
||
.single();
|
||
|
||
if (error) {
|
||
if (error.code === "23505") {
|
||
return res.status(400).json({ error: "Username already taken" });
|
||
}
|
||
throw error;
|
||
}
|
||
|
||
return res.status(201).json(data);
|
||
} catch (e: any) {
|
||
console.error("[Creator API] Error creating creator:", e?.message);
|
||
return res
|
||
.status(500)
|
||
.json({ error: "Failed to create creator profile" });
|
||
}
|
||
});
|
||
|
||
// Update creator profile
|
||
app.put("/api/creators/:id", async (req, res) => {
|
||
try {
|
||
const creatorId = String(req.params.id || "").trim();
|
||
if (!creatorId) {
|
||
return res.status(400).json({ error: "creator id required" });
|
||
}
|
||
|
||
const {
|
||
bio,
|
||
skills,
|
||
avatar_url,
|
||
experience_level,
|
||
primary_arm,
|
||
arm_affiliations,
|
||
is_discoverable,
|
||
allow_recommendations,
|
||
} = req.body;
|
||
|
||
const { data, error } = await adminSupabase
|
||
.from("aethex_creators")
|
||
.update({
|
||
bio,
|
||
skills: Array.isArray(skills) ? skills : undefined,
|
||
avatar_url,
|
||
experience_level,
|
||
primary_arm,
|
||
arm_affiliations: Array.isArray(arm_affiliations)
|
||
? arm_affiliations
|
||
: undefined,
|
||
is_discoverable,
|
||
allow_recommendations,
|
||
updated_at: new Date().toISOString(),
|
||
})
|
||
.eq("id", creatorId)
|
||
.select()
|
||
.single();
|
||
|
||
if (error) throw error;
|
||
|
||
return res.json(data);
|
||
} catch (e: any) {
|
||
console.error("[Creator API] Error updating creator:", e?.message);
|
||
return res
|
||
.status(500)
|
||
.json({ error: "Failed to update creator profile" });
|
||
}
|
||
});
|
||
|
||
// Get all opportunities with filters
|
||
app.get("/api/opportunities", async (req, res) => {
|
||
try {
|
||
const arm = String(req.query.arm || "").trim();
|
||
const page = Math.max(1, Number(req.query.page) || 1);
|
||
const limit = Math.max(1, Math.min(100, Number(req.query.limit) || 20));
|
||
const sort = String(req.query.sort || "recent");
|
||
const search = String(req.query.search || "").trim();
|
||
const jobType = String(req.query.jobType || "").trim();
|
||
const experienceLevel = String(req.query.experienceLevel || "").trim();
|
||
|
||
let query = adminSupabase
|
||
.from("aethex_opportunities")
|
||
.select(
|
||
`
|
||
id,
|
||
title,
|
||
description,
|
||
job_type,
|
||
salary_min,
|
||
salary_max,
|
||
experience_level,
|
||
arm_affiliation,
|
||
posted_by_id,
|
||
aethex_creators!aethex_opportunities_posted_by_id_fkey(username, avatar_url),
|
||
status,
|
||
created_at
|
||
`,
|
||
{ count: "exact" },
|
||
)
|
||
.eq("status", "open");
|
||
|
||
if (arm) {
|
||
query = query.eq("arm_affiliation", arm);
|
||
}
|
||
|
||
if (search) {
|
||
query = query.or(
|
||
`title.ilike.%${search}%,description.ilike.%${search}%`,
|
||
);
|
||
}
|
||
|
||
if (jobType) {
|
||
query = query.eq("job_type", jobType);
|
||
}
|
||
|
||
if (experienceLevel) {
|
||
query = query.eq("experience_level", experienceLevel);
|
||
}
|
||
|
||
const ascending = sort === "oldest";
|
||
query = query.order("created_at", { ascending });
|
||
|
||
const start = (page - 1) * limit;
|
||
query = query.range(start, start + limit - 1);
|
||
|
||
const { data, error, count } = await query;
|
||
|
||
if (error) throw error;
|
||
|
||
return res.json({
|
||
data,
|
||
pagination: {
|
||
page,
|
||
limit,
|
||
total: count || 0,
|
||
pages: Math.ceil((count || 0) / limit),
|
||
},
|
||
});
|
||
} catch (e: any) {
|
||
console.error(
|
||
"[Opportunities API] Error fetching opportunities:",
|
||
e?.message,
|
||
);
|
||
return res.status(500).json({ error: "Failed to fetch opportunities" });
|
||
}
|
||
});
|
||
|
||
// Get opportunity by ID
|
||
app.get("/api/opportunities/:id", async (req, res) => {
|
||
try {
|
||
const opportunityId = String(req.params.id || "").trim();
|
||
if (!opportunityId) {
|
||
return res.status(400).json({ error: "opportunity id required" });
|
||
}
|
||
|
||
const { data, error } = await adminSupabase
|
||
.from("aethex_opportunities")
|
||
.select(
|
||
`
|
||
id,
|
||
title,
|
||
description,
|
||
job_type,
|
||
salary_min,
|
||
salary_max,
|
||
experience_level,
|
||
arm_affiliation,
|
||
posted_by_id,
|
||
aethex_creators!aethex_opportunities_posted_by_id_fkey(id, username, bio, avatar_url),
|
||
status,
|
||
created_at,
|
||
updated_at
|
||
`,
|
||
)
|
||
.eq("id", opportunityId)
|
||
.eq("status", "open")
|
||
.single();
|
||
|
||
if (error) {
|
||
if (error.code === "PGRST116") {
|
||
return res.status(404).json({ error: "Opportunity not found" });
|
||
}
|
||
throw error;
|
||
}
|
||
|
||
return res.json(data);
|
||
} catch (e: any) {
|
||
console.error(
|
||
"[Opportunities API] Error fetching opportunity:",
|
||
e?.message,
|
||
);
|
||
return res.status(500).json({ error: "Failed to fetch opportunity" });
|
||
}
|
||
});
|
||
|
||
// Create opportunity
|
||
app.post("/api/opportunities", async (req, res) => {
|
||
try {
|
||
const {
|
||
user_id,
|
||
title,
|
||
description,
|
||
job_type,
|
||
salary_min,
|
||
salary_max,
|
||
experience_level,
|
||
arm_affiliation,
|
||
} = req.body;
|
||
|
||
if (!user_id || !title) {
|
||
return res.status(400).json({ error: "user_id and title required" });
|
||
}
|
||
|
||
const { data: creator } = await adminSupabase
|
||
.from("aethex_creators")
|
||
.select("id")
|
||
.eq("user_id", user_id)
|
||
.single();
|
||
|
||
if (!creator) {
|
||
return res.status(404).json({
|
||
error: "Creator profile not found. Create profile first.",
|
||
});
|
||
}
|
||
|
||
const { data, error } = await adminSupabase
|
||
.from("aethex_opportunities")
|
||
.insert({
|
||
title,
|
||
description: description || null,
|
||
job_type: job_type || null,
|
||
salary_min: salary_min || null,
|
||
salary_max: salary_max || null,
|
||
experience_level: experience_level || null,
|
||
arm_affiliation: arm_affiliation || null,
|
||
posted_by_id: creator.id,
|
||
status: "open",
|
||
})
|
||
.select()
|
||
.single();
|
||
|
||
if (error) throw error;
|
||
|
||
return res.status(201).json(data);
|
||
} catch (e: any) {
|
||
console.error(
|
||
"[Opportunities API] Error creating opportunity:",
|
||
e?.message,
|
||
);
|
||
return res.status(500).json({ error: "Failed to create opportunity" });
|
||
}
|
||
});
|
||
|
||
// Update opportunity
|
||
app.put("/api/opportunities/:id", async (req, res) => {
|
||
try {
|
||
const opportunityId = String(req.params.id || "").trim();
|
||
const {
|
||
user_id,
|
||
title,
|
||
description,
|
||
job_type,
|
||
salary_min,
|
||
salary_max,
|
||
experience_level,
|
||
status,
|
||
} = req.body;
|
||
|
||
if (!opportunityId || !user_id) {
|
||
return res
|
||
.status(400)
|
||
.json({ error: "opportunity id and user_id required" });
|
||
}
|
||
|
||
const { data: opportunity } = await adminSupabase
|
||
.from("aethex_opportunities")
|
||
.select("posted_by_id")
|
||
.eq("id", opportunityId)
|
||
.single();
|
||
|
||
if (!opportunity) {
|
||
return res.status(404).json({ error: "Opportunity not found" });
|
||
}
|
||
|
||
const { data: creator } = await adminSupabase
|
||
.from("aethex_creators")
|
||
.select("id")
|
||
.eq("user_id", user_id)
|
||
.single();
|
||
|
||
if (creator?.id !== opportunity.posted_by_id) {
|
||
return res.status(403).json({ error: "Unauthorized" });
|
||
}
|
||
|
||
const { data, error } = await adminSupabase
|
||
.from("aethex_opportunities")
|
||
.update({
|
||
title,
|
||
description,
|
||
job_type,
|
||
salary_min,
|
||
salary_max,
|
||
experience_level,
|
||
status,
|
||
updated_at: new Date().toISOString(),
|
||
})
|
||
.eq("id", opportunityId)
|
||
.select()
|
||
.single();
|
||
|
||
if (error) throw error;
|
||
|
||
return res.json(data);
|
||
} catch (e: any) {
|
||
console.error(
|
||
"[Opportunities API] Error updating opportunity:",
|
||
e?.message,
|
||
);
|
||
return res.status(500).json({ error: "Failed to update opportunity" });
|
||
}
|
||
});
|
||
|
||
// Get my applications
|
||
app.get("/api/applications", async (req, res) => {
|
||
try {
|
||
const userId = String(req.query.user_id || "").trim();
|
||
const page = Math.max(1, Number(req.query.page) || 1);
|
||
const limit = Math.max(1, Math.min(100, Number(req.query.limit) || 10));
|
||
const status = String(req.query.status || "").trim();
|
||
|
||
if (!userId) {
|
||
return res.status(400).json({ error: "user_id required" });
|
||
}
|
||
|
||
const { data: creator } = await adminSupabase
|
||
.from("aethex_creators")
|
||
.select("id")
|
||
.eq("user_id", userId)
|
||
.single();
|
||
|
||
if (!creator) {
|
||
return res.status(404).json({ error: "Creator profile not found" });
|
||
}
|
||
|
||
let query = adminSupabase
|
||
.from("aethex_applications")
|
||
.select(
|
||
`
|
||
id,
|
||
creator_id,
|
||
opportunity_id,
|
||
status,
|
||
cover_letter,
|
||
response_message,
|
||
applied_at,
|
||
updated_at,
|
||
aethex_opportunities(id, title, arm_affiliation, job_type, posted_by_id, aethex_creators!aethex_opportunities_posted_by_id_fkey(username, avatar_url))
|
||
`,
|
||
{ count: "exact" },
|
||
)
|
||
.eq("creator_id", creator.id);
|
||
|
||
if (status) {
|
||
query = query.eq("status", status);
|
||
}
|
||
|
||
query = query.order("applied_at", { ascending: false });
|
||
|
||
const start = (page - 1) * limit;
|
||
query = query.range(start, start + limit - 1);
|
||
|
||
const { data, error, count } = await query;
|
||
|
||
if (error) throw error;
|
||
|
||
return res.json({
|
||
data,
|
||
pagination: {
|
||
page,
|
||
limit,
|
||
total: count || 0,
|
||
pages: Math.ceil((count || 0) / limit),
|
||
},
|
||
});
|
||
} catch (e: any) {
|
||
console.error(
|
||
"[Applications API] Error fetching applications:",
|
||
e?.message,
|
||
);
|
||
return res.status(500).json({ error: "Failed to fetch applications" });
|
||
}
|
||
});
|
||
|
||
// Submit application
|
||
app.post("/api/applications", async (req, res) => {
|
||
try {
|
||
const { user_id, opportunity_id, cover_letter } = req.body;
|
||
|
||
if (!user_id || !opportunity_id) {
|
||
return res
|
||
.status(400)
|
||
.json({ error: "user_id and opportunity_id required" });
|
||
}
|
||
|
||
const { data: creator } = await adminSupabase
|
||
.from("aethex_creators")
|
||
.select("id")
|
||
.eq("user_id", user_id)
|
||
.single();
|
||
|
||
if (!creator) {
|
||
return res.status(404).json({ error: "Creator profile not found" });
|
||
}
|
||
|
||
const { data: opportunity } = await adminSupabase
|
||
.from("aethex_opportunities")
|
||
.select("id")
|
||
.eq("id", opportunity_id)
|
||
.eq("status", "open")
|
||
.single();
|
||
|
||
if (!opportunity) {
|
||
return res
|
||
.status(404)
|
||
.json({ error: "Opportunity not found or closed" });
|
||
}
|
||
|
||
const { data: existing } = await adminSupabase
|
||
.from("aethex_applications")
|
||
.select("id")
|
||
.eq("creator_id", creator.id)
|
||
.eq("opportunity_id", opportunity_id)
|
||
.maybeSingle();
|
||
|
||
if (existing) {
|
||
return res
|
||
.status(400)
|
||
.json({ error: "You have already applied to this opportunity" });
|
||
}
|
||
|
||
const { data, error } = await adminSupabase
|
||
.from("aethex_applications")
|
||
.insert({
|
||
creator_id: creator.id,
|
||
opportunity_id,
|
||
cover_letter: cover_letter || null,
|
||
status: "submitted",
|
||
})
|
||
.select()
|
||
.single();
|
||
|
||
if (error) throw error;
|
||
|
||
// Notify opportunity poster of new application
|
||
if (opportunity?.posted_by_id) {
|
||
try {
|
||
const { data: creatorProfile } = await adminSupabase
|
||
.from("aethex_creators")
|
||
.select("user_id, full_name")
|
||
.eq("id", creator.id)
|
||
.single();
|
||
|
||
await adminSupabase.from("notifications").insert({
|
||
user_id: opportunity.posted_by_id,
|
||
type: "info",
|
||
title: `📋 New Application: ${opportunity.title}`,
|
||
message: `${creatorProfile?.full_name || "A creator"} applied for your opportunity.`,
|
||
});
|
||
} catch (notifError) {
|
||
console.warn(
|
||
"Failed to create application notification:",
|
||
notifError,
|
||
);
|
||
}
|
||
}
|
||
|
||
return res.status(201).json(data);
|
||
} catch (e: any) {
|
||
console.error(
|
||
"[Applications API] Error submitting application:",
|
||
e?.message,
|
||
);
|
||
return res.status(500).json({ error: "Failed to submit application" });
|
||
}
|
||
});
|
||
|
||
// Update application status
|
||
app.put("/api/applications/:id", async (req, res) => {
|
||
try {
|
||
const applicationId = String(req.params.id || "").trim();
|
||
const { user_id, status, response_message } = req.body;
|
||
|
||
if (!applicationId || !user_id) {
|
||
return res
|
||
.status(400)
|
||
.json({ error: "application id and user_id required" });
|
||
}
|
||
|
||
const { data: application } = await adminSupabase
|
||
.from("aethex_applications")
|
||
.select(
|
||
`
|
||
id,
|
||
opportunity_id,
|
||
aethex_opportunities(posted_by_id)
|
||
`,
|
||
)
|
||
.eq("id", applicationId)
|
||
.single();
|
||
|
||
if (!application) {
|
||
return res.status(404).json({ error: "Application not found" });
|
||
}
|
||
|
||
const { data: creator } = await adminSupabase
|
||
.from("aethex_creators")
|
||
.select("id")
|
||
.eq("user_id", user_id)
|
||
.single();
|
||
|
||
if (creator?.id !== application.aethex_opportunities.posted_by_id) {
|
||
return res.status(403).json({ error: "Unauthorized" });
|
||
}
|
||
|
||
const { data, error } = await adminSupabase
|
||
.from("aethex_applications")
|
||
.update({
|
||
status,
|
||
response_message: response_message || null,
|
||
updated_at: new Date().toISOString(),
|
||
})
|
||
.eq("id", applicationId)
|
||
.select()
|
||
.single();
|
||
|
||
if (error) throw error;
|
||
|
||
// Notify applicant of status change
|
||
if (data) {
|
||
try {
|
||
const { data: applicantProfile } = await adminSupabase
|
||
.from("aethex_creators")
|
||
.select("user_id")
|
||
.eq("id", data.creator_id)
|
||
.single();
|
||
|
||
if (applicantProfile?.user_id) {
|
||
const statusEmoji =
|
||
status === "accepted"
|
||
? "✅"
|
||
: status === "rejected"
|
||
? "❌"
|
||
: "📝";
|
||
const statusMessage =
|
||
status === "accepted"
|
||
? "accepted"
|
||
: status === "rejected"
|
||
? "rejected"
|
||
: "updated";
|
||
|
||
await adminSupabase.from("notifications").insert({
|
||
user_id: applicantProfile.user_id,
|
||
type:
|
||
status === "accepted"
|
||
? "success"
|
||
: status === "rejected"
|
||
? "error"
|
||
: "info",
|
||
title: `${statusEmoji} Application ${statusMessage}`,
|
||
message:
|
||
response_message ||
|
||
`Your application has been ${statusMessage}.`,
|
||
});
|
||
}
|
||
} catch (notifError) {
|
||
console.warn("Failed to create status notification:", notifError);
|
||
}
|
||
}
|
||
|
||
return res.json(data);
|
||
} catch (e: any) {
|
||
console.error(
|
||
"[Applications API] Error updating application:",
|
||
e?.message,
|
||
);
|
||
return res.status(500).json({ error: "Failed to update application" });
|
||
}
|
||
});
|
||
|
||
// Withdraw application
|
||
app.delete("/api/applications/:id", async (req, res) => {
|
||
try {
|
||
const applicationId = String(req.params.id || "").trim();
|
||
const { user_id } = req.body;
|
||
|
||
if (!applicationId || !user_id) {
|
||
return res
|
||
.status(400)
|
||
.json({ error: "application id and user_id required" });
|
||
}
|
||
|
||
const { data: application } = await adminSupabase
|
||
.from("aethex_applications")
|
||
.select("creator_id")
|
||
.eq("id", applicationId)
|
||
.single();
|
||
|
||
if (!application) {
|
||
return res.status(404).json({ error: "Application not found" });
|
||
}
|
||
|
||
const { data: creator } = await adminSupabase
|
||
.from("aethex_creators")
|
||
.select("id")
|
||
.eq("user_id", user_id)
|
||
.single();
|
||
|
||
if (creator?.id !== application.creator_id) {
|
||
return res.status(403).json({ error: "Unauthorized" });
|
||
}
|
||
|
||
const { error } = await adminSupabase
|
||
.from("aethex_applications")
|
||
.delete()
|
||
.eq("id", applicationId);
|
||
|
||
if (error) throw error;
|
||
|
||
return res.json({ ok: true });
|
||
} catch (e: any) {
|
||
console.error(
|
||
"[Applications API] Error withdrawing application:",
|
||
e?.message,
|
||
);
|
||
return res
|
||
.status(500)
|
||
.json({ error: "Failed to withdraw application" });
|
||
}
|
||
});
|
||
|
||
// Link DevConnect account
|
||
app.post("/api/devconnect/link", async (req, res) => {
|
||
try {
|
||
const { user_id, devconnect_username, devconnect_profile_url } =
|
||
req.body;
|
||
|
||
if (!user_id || !devconnect_username) {
|
||
return res
|
||
.status(400)
|
||
.json({ error: "user_id and devconnect_username required" });
|
||
}
|
||
|
||
const { data: creator } = await adminSupabase
|
||
.from("aethex_creators")
|
||
.select("id")
|
||
.eq("user_id", user_id)
|
||
.single();
|
||
|
||
if (!creator) {
|
||
return res.status(404).json({
|
||
error: "Creator profile not found. Create profile first.",
|
||
});
|
||
}
|
||
|
||
const { data: existing } = await adminSupabase
|
||
.from("aethex_devconnect_links")
|
||
.select("id")
|
||
.eq("aethex_creator_id", creator.id)
|
||
.maybeSingle();
|
||
|
||
let result;
|
||
let status = 201;
|
||
|
||
if (existing) {
|
||
const { data, error } = await adminSupabase
|
||
.from("aethex_devconnect_links")
|
||
.update({
|
||
devconnect_username,
|
||
devconnect_profile_url:
|
||
devconnect_profile_url ||
|
||
`https://dev-link.me/${devconnect_username}`,
|
||
})
|
||
.eq("aethex_creator_id", creator.id)
|
||
.select()
|
||
.single();
|
||
|
||
if (error) throw error;
|
||
result = data;
|
||
status = 200;
|
||
} else {
|
||
const { data, error } = await adminSupabase
|
||
.from("aethex_devconnect_links")
|
||
.insert({
|
||
aethex_creator_id: creator.id,
|
||
devconnect_username,
|
||
devconnect_profile_url:
|
||
devconnect_profile_url ||
|
||
`https://dev-link.me/${devconnect_username}`,
|
||
})
|
||
.select()
|
||
.single();
|
||
|
||
if (error) throw error;
|
||
result = data;
|
||
}
|
||
|
||
await adminSupabase
|
||
.from("aethex_creators")
|
||
.update({ devconnect_linked: true })
|
||
.eq("id", creator.id);
|
||
|
||
return res.status(status).json(result);
|
||
} catch (e: any) {
|
||
console.error("[DevConnect API] Error linking account:", e?.message);
|
||
return res
|
||
.status(500)
|
||
.json({ error: "Failed to link DevConnect account" });
|
||
}
|
||
});
|
||
|
||
// Get DevConnect link
|
||
app.get("/api/devconnect/link", async (req, res) => {
|
||
try {
|
||
const userId = String(req.query.user_id || "").trim();
|
||
|
||
if (!userId) {
|
||
return res.status(400).json({ error: "user_id required" });
|
||
}
|
||
|
||
const { data: creator } = await adminSupabase
|
||
.from("aethex_creators")
|
||
.select("id")
|
||
.eq("user_id", userId)
|
||
.single();
|
||
|
||
if (!creator) {
|
||
return res.status(404).json({ error: "Creator profile not found" });
|
||
}
|
||
|
||
const { data, error } = await adminSupabase
|
||
.from("aethex_devconnect_links")
|
||
.select("*")
|
||
.eq("aethex_creator_id", creator.id)
|
||
.maybeSingle();
|
||
|
||
if (error && error.code !== "PGRST116") {
|
||
throw error;
|
||
}
|
||
|
||
return res.json({ data: data || null });
|
||
} catch (e: any) {
|
||
console.error("[DevConnect API] Error fetching link:", e?.message);
|
||
return res
|
||
.status(500)
|
||
.json({ error: "Failed to fetch DevConnect link" });
|
||
}
|
||
});
|
||
|
||
// Unlink DevConnect account
|
||
app.delete("/api/devconnect/link", async (req, res) => {
|
||
try {
|
||
const { user_id } = req.body;
|
||
|
||
if (!user_id) {
|
||
return res.status(400).json({ error: "user_id required" });
|
||
}
|
||
|
||
const { data: creator } = await adminSupabase
|
||
.from("aethex_creators")
|
||
.select("id")
|
||
.eq("user_id", user_id)
|
||
.single();
|
||
|
||
if (!creator) {
|
||
return res.status(404).json({ error: "Creator profile not found" });
|
||
}
|
||
|
||
const { error } = await adminSupabase
|
||
.from("aethex_devconnect_links")
|
||
.delete()
|
||
.eq("aethex_creator_id", creator.id);
|
||
|
||
if (error) throw error;
|
||
|
||
await adminSupabase
|
||
.from("aethex_creators")
|
||
.update({ devconnect_linked: false })
|
||
.eq("id", creator.id);
|
||
|
||
return res.json({ ok: true });
|
||
} catch (e: any) {
|
||
console.error("[DevConnect API] Error unlinking account:", e?.message);
|
||
return res
|
||
.status(500)
|
||
.json({ error: "Failed to unlink DevConnect account" });
|
||
}
|
||
});
|
||
|
||
// Ethos Tracks API
|
||
app.get("/api/ethos/tracks", async (req, res) => {
|
||
try {
|
||
const {
|
||
limit = 50,
|
||
offset = 0,
|
||
genre,
|
||
licenseType,
|
||
search,
|
||
} = req.query;
|
||
|
||
let dbQuery = adminSupabase
|
||
.from("ethos_tracks")
|
||
.select(
|
||
`
|
||
id,
|
||
user_id,
|
||
title,
|
||
description,
|
||
file_url,
|
||
duration_seconds,
|
||
genre,
|
||
license_type,
|
||
bpm,
|
||
is_published,
|
||
download_count,
|
||
rating,
|
||
price,
|
||
created_at,
|
||
updated_at,
|
||
user_profiles(id, full_name, avatar_url)
|
||
`,
|
||
{ count: "exact" },
|
||
)
|
||
.eq("is_published", true)
|
||
.order("created_at", { ascending: false });
|
||
|
||
if (genre) dbQuery = dbQuery.contains("genre", [genre]);
|
||
if (licenseType) dbQuery = dbQuery.eq("license_type", licenseType);
|
||
if (search) {
|
||
dbQuery = dbQuery.or(
|
||
`title.ilike.%${search}%,description.ilike.%${search}%`,
|
||
);
|
||
}
|
||
|
||
const { data, error, count } = await dbQuery.range(
|
||
Number(offset),
|
||
Number(offset) + Number(limit) - 1,
|
||
);
|
||
|
||
if (error) throw error;
|
||
|
||
res.json({
|
||
data: data || [],
|
||
total: count,
|
||
limit: Number(limit),
|
||
offset: Number(offset),
|
||
});
|
||
} catch (e: any) {
|
||
console.error("[Ethos Tracks API] Error fetching tracks:", e?.message);
|
||
res.status(500).json({ error: e?.message || "Failed to fetch tracks" });
|
||
}
|
||
});
|
||
|
||
app.post("/api/ethos/tracks", async (req, res) => {
|
||
try {
|
||
const userId = req.headers["x-user-id"] || req.body?.user_id;
|
||
if (!userId) {
|
||
return res.status(401).json({ error: "Unauthorized" });
|
||
}
|
||
|
||
const {
|
||
title,
|
||
description,
|
||
file_url,
|
||
duration_seconds,
|
||
genre,
|
||
license_type,
|
||
bpm,
|
||
is_published,
|
||
price,
|
||
} = req.body;
|
||
|
||
if (!title || !file_url || !license_type) {
|
||
return res.status(400).json({
|
||
error: "Missing required fields: title, file_url, license_type",
|
||
});
|
||
}
|
||
|
||
const { data, error } = await adminSupabase
|
||
.from("ethos_tracks")
|
||
.insert([
|
||
{
|
||
user_id: userId,
|
||
title,
|
||
description,
|
||
file_url,
|
||
duration_seconds,
|
||
genre: genre || [],
|
||
license_type,
|
||
bpm,
|
||
is_published: is_published !== false,
|
||
price,
|
||
},
|
||
])
|
||
.select();
|
||
|
||
if (error) throw error;
|
||
|
||
if (license_type === "ecosystem" && data && data[0]) {
|
||
const trackId = data[0].id;
|
||
await adminSupabase.from("ethos_ecosystem_licenses").insert([
|
||
{
|
||
track_id: trackId,
|
||
artist_id: userId,
|
||
accepted_at: new Date().toISOString(),
|
||
},
|
||
]);
|
||
}
|
||
|
||
res.status(201).json(data[0]);
|
||
} catch (e: any) {
|
||
console.error("[Ethos Tracks API] Error creating track:", e?.message);
|
||
res.status(500).json({ error: e?.message || "Failed to create track" });
|
||
}
|
||
});
|
||
|
||
// Ethos Artists API
|
||
app.get("/api/ethos/artists", async (req, res) => {
|
||
try {
|
||
const { id, limit = 50, offset = 0, verified, forHire } = req.query;
|
||
|
||
if (id) {
|
||
// Get single artist by ID
|
||
const { data: artist, error: artistError } = await adminSupabase
|
||
.from("ethos_artist_profiles")
|
||
.select(
|
||
`
|
||
user_id,
|
||
skills,
|
||
for_hire,
|
||
bio,
|
||
portfolio_url,
|
||
sample_price_track,
|
||
sample_price_sfx,
|
||
sample_price_score,
|
||
turnaround_days,
|
||
verified,
|
||
total_downloads,
|
||
created_at,
|
||
user_profiles(id, full_name, avatar_url, email)
|
||
`,
|
||
)
|
||
.eq("user_id", id)
|
||
.single();
|
||
|
||
if (artistError && artistError.code !== "PGRST116") throw artistError;
|
||
|
||
if (!artist) {
|
||
return res.status(404).json({ error: "Artist not found" });
|
||
}
|
||
|
||
const { data: tracks } = await adminSupabase
|
||
.from("ethos_tracks")
|
||
.select("*")
|
||
.eq("user_id", id)
|
||
.eq("is_published", true)
|
||
.order("created_at", { ascending: false });
|
||
|
||
return res.json({
|
||
...artist,
|
||
tracks: tracks || [],
|
||
});
|
||
}
|
||
|
||
// Get list of artists
|
||
let dbQuery = adminSupabase.from("ethos_artist_profiles").select(
|
||
`
|
||
user_id,
|
||
skills,
|
||
for_hire,
|
||
bio,
|
||
portfolio_url,
|
||
sample_price_track,
|
||
sample_price_sfx,
|
||
sample_price_score,
|
||
turnaround_days,
|
||
verified,
|
||
total_downloads,
|
||
created_at,
|
||
user_profiles(id, full_name, avatar_url)
|
||
`,
|
||
{ count: "exact" },
|
||
);
|
||
|
||
if (verified === "true") dbQuery = dbQuery.eq("verified", true);
|
||
if (forHire === "true") dbQuery = dbQuery.eq("for_hire", true);
|
||
|
||
const { data, error, count } = await dbQuery
|
||
.order("verified", { ascending: false })
|
||
.order("total_downloads", { ascending: false })
|
||
.range(Number(offset), Number(offset) + Number(limit) - 1);
|
||
|
||
if (error) throw error;
|
||
|
||
res.json({
|
||
data: data || [],
|
||
total: count,
|
||
limit: Number(limit),
|
||
offset: Number(offset),
|
||
});
|
||
} catch (e: any) {
|
||
console.error(
|
||
"[Ethos Artists API] Error fetching artists:",
|
||
e?.message,
|
||
);
|
||
res
|
||
.status(500)
|
||
.json({ error: e?.message || "Failed to fetch artists" });
|
||
}
|
||
});
|
||
|
||
app.put("/api/ethos/artists", async (req, res) => {
|
||
try {
|
||
const userId = req.headers["x-user-id"] || req.body?.user_id;
|
||
if (!userId) {
|
||
return res.status(401).json({ error: "Unauthorized" });
|
||
}
|
||
|
||
const {
|
||
skills,
|
||
for_hire,
|
||
bio,
|
||
portfolio_url,
|
||
sample_price_track,
|
||
sample_price_sfx,
|
||
sample_price_score,
|
||
turnaround_days,
|
||
} = req.body;
|
||
|
||
const { data, error } = await adminSupabase
|
||
.from("ethos_artist_profiles")
|
||
.upsert(
|
||
{
|
||
user_id: userId,
|
||
skills: skills || [],
|
||
for_hire: for_hire !== false,
|
||
bio,
|
||
portfolio_url,
|
||
sample_price_track,
|
||
sample_price_sfx,
|
||
sample_price_score,
|
||
turnaround_days,
|
||
},
|
||
{ onConflict: "user_id" },
|
||
)
|
||
.select();
|
||
|
||
if (error) throw error;
|
||
res.json(data[0]);
|
||
} catch (e: any) {
|
||
console.error("[Ethos Artists API] Error updating artist:", e?.message);
|
||
res
|
||
.status(500)
|
||
.json({ error: e?.message || "Failed to update artist profile" });
|
||
}
|
||
});
|
||
|
||
// Task assignment with notification
|
||
app.post("/api/tasks", async (req, res) => {
|
||
try {
|
||
const {
|
||
project_id,
|
||
title,
|
||
description,
|
||
assignee_id,
|
||
due_date,
|
||
user_id,
|
||
} = req.body;
|
||
|
||
if (!project_id || !title || !user_id) {
|
||
return res
|
||
.status(400)
|
||
.json({ error: "project_id, title, and user_id required" });
|
||
}
|
||
|
||
const { data, error } = await adminSupabase
|
||
.from("project_tasks")
|
||
.insert({
|
||
project_id,
|
||
title,
|
||
description: description || null,
|
||
assignee_id: assignee_id || null,
|
||
due_date: due_date || null,
|
||
status: "open",
|
||
})
|
||
.select()
|
||
.single();
|
||
|
||
if (error) throw error;
|
||
|
||
// Notify assignee if assigned
|
||
if (assignee_id && assignee_id !== user_id) {
|
||
try {
|
||
const { data: assigner } = await adminSupabase
|
||
.from("user_profiles")
|
||
.select("full_name, username")
|
||
.eq("id", user_id)
|
||
.single();
|
||
|
||
const assignerName =
|
||
(assigner as any)?.full_name ||
|
||
(assigner as any)?.username ||
|
||
"Someone";
|
||
await adminSupabase.from("notifications").insert({
|
||
user_id: assignee_id,
|
||
type: "info",
|
||
title: "📋 Task assigned to you",
|
||
message: `${assignerName} assigned you a task: "${title}"`,
|
||
});
|
||
} catch (notifError) {
|
||
console.warn("Failed to create task notification:", notifError);
|
||
}
|
||
}
|
||
|
||
return res.status(201).json(data);
|
||
} catch (e: any) {
|
||
console.error("[Tasks API] Error creating task:", e?.message);
|
||
return res.status(500).json({ error: "Failed to create task" });
|
||
}
|
||
});
|
||
|
||
// Assign task with notification
|
||
app.put("/api/tasks/:id/assign", async (req, res) => {
|
||
try {
|
||
const taskId = String(req.params.id || "").trim();
|
||
const { assignee_id, user_id } = req.body;
|
||
|
||
if (!taskId || !assignee_id || !user_id) {
|
||
return res
|
||
.status(400)
|
||
.json({ error: "task id, assignee_id, and user_id required" });
|
||
}
|
||
|
||
const { data: task } = await adminSupabase
|
||
.from("project_tasks")
|
||
.select("title")
|
||
.eq("id", taskId)
|
||
.single();
|
||
|
||
if (!task) {
|
||
return res.status(404).json({ error: "Task not found" });
|
||
}
|
||
|
||
const { data, error } = await adminSupabase
|
||
.from("project_tasks")
|
||
.update({ assignee_id })
|
||
.eq("id", taskId)
|
||
.select()
|
||
.single();
|
||
|
||
if (error) throw error;
|
||
|
||
// Notify assignee
|
||
try {
|
||
const { data: assigner } = await adminSupabase
|
||
.from("user_profiles")
|
||
.select("full_name, username")
|
||
.eq("id", user_id)
|
||
.single();
|
||
|
||
const assignerName =
|
||
(assigner as any)?.full_name ||
|
||
(assigner as any)?.username ||
|
||
"Someone";
|
||
await adminSupabase.from("notifications").insert({
|
||
user_id: assignee_id,
|
||
type: "info",
|
||
title: "📋 Task assigned to you",
|
||
message: `${assignerName} assigned you: "${task.title}"`,
|
||
});
|
||
} catch (notifError) {
|
||
console.warn("Failed to create assignment notification:", notifError);
|
||
}
|
||
|
||
return res.json(data);
|
||
} catch (e: any) {
|
||
console.error("[Tasks API] Error assigning task:", e?.message);
|
||
return res.status(500).json({ error: "Failed to assign task" });
|
||
}
|
||
});
|
||
|
||
// Moderation report with staff notification
|
||
app.post("/api/moderation/reports", async (req, res) => {
|
||
try {
|
||
const { reported_user_id, report_type, description, reporter_id } =
|
||
req.body;
|
||
|
||
if (!reported_user_id || !report_type || !reporter_id) {
|
||
return res.status(400).json({
|
||
error: "reported_user_id, report_type, and reporter_id required",
|
||
});
|
||
}
|
||
|
||
const { data, error } = await adminSupabase
|
||
.from("moderation_reports")
|
||
.insert({
|
||
reported_user_id,
|
||
report_type,
|
||
description: description || null,
|
||
reporter_id,
|
||
status: "pending",
|
||
})
|
||
.select()
|
||
.single();
|
||
|
||
if (error) throw error;
|
||
|
||
// Notify staff members
|
||
try {
|
||
const { data: staffUsers } = await adminSupabase
|
||
.from("user_roles")
|
||
.select("user_id")
|
||
.in("role", ["owner", "admin", "moderator"]);
|
||
|
||
if (staffUsers && staffUsers.length > 0) {
|
||
const notifications = staffUsers.map((staff: any) => ({
|
||
user_id: staff.user_id,
|
||
type: "warning",
|
||
title: "🚨 New moderation report",
|
||
message: `A ${report_type} report has been submitted. Please review.`,
|
||
}));
|
||
|
||
await adminSupabase.from("notifications").insert(notifications);
|
||
}
|
||
} catch (notifError) {
|
||
console.warn("Failed to notify staff:", notifError);
|
||
}
|
||
|
||
return res.status(201).json(data);
|
||
} catch (e: any) {
|
||
console.error("[Moderation API] Error creating report:", e?.message);
|
||
return res.status(500).json({ error: "Failed to create report" });
|
||
}
|
||
});
|
||
|
||
// Staff Members API
|
||
app.get("/api/staff/members", async (_req, res) => {
|
||
try {
|
||
console.log(
|
||
"[Staff] GET /api/staff/members - adminSupabase initialized:",
|
||
!!adminSupabase,
|
||
);
|
||
|
||
if (!adminSupabase) {
|
||
console.error("[Staff] adminSupabase is not initialized");
|
||
return res.status(500).json({
|
||
error: "Supabase client not initialized",
|
||
message: "SUPABASE_URL or SUPABASE_SERVICE_ROLE not set",
|
||
});
|
||
}
|
||
|
||
const { data, error } = await adminSupabase
|
||
.from("staff_members")
|
||
.select("*")
|
||
.order("full_name", { ascending: true });
|
||
|
||
if (error) {
|
||
console.error("[Staff] Error fetching staff members:", error);
|
||
if (isTableMissing(error)) {
|
||
console.log("[Staff] Table not found, returning empty array");
|
||
return res.json([]);
|
||
}
|
||
return res.status(500).json({ error: error.message });
|
||
}
|
||
|
||
console.log(
|
||
"[Staff] Successfully fetched",
|
||
(data || []).length,
|
||
"staff members",
|
||
);
|
||
return res.json(data || []);
|
||
} catch (e: any) {
|
||
console.error("[Staff] Unexpected error:", e);
|
||
return res.status(500).json({ error: e?.message || String(e) });
|
||
}
|
||
});
|
||
|
||
app.get("/api/staff/members/seed", async (req, res) => {
|
||
try {
|
||
// Ensure response headers are set correctly
|
||
res.setHeader("Content-Type", "application/json");
|
||
|
||
if (!adminSupabase) {
|
||
console.error("[Staff Seed] adminSupabase is not initialized");
|
||
return res.status(500).json({
|
||
error: "Supabase client not initialized",
|
||
message: "SUPABASE_URL or SUPABASE_SERVICE_ROLE not set",
|
||
});
|
||
}
|
||
|
||
// First, check if table exists and is accessible
|
||
const { data: existingData, error: existingError } = await adminSupabase
|
||
.from("staff_members")
|
||
.select("count")
|
||
.limit(1);
|
||
|
||
if (existingError) {
|
||
console.error("[Staff Seed] Table check error:", existingError);
|
||
return res.status(500).json({
|
||
error: "Cannot access staff_members table",
|
||
tableError: existingError,
|
||
});
|
||
}
|
||
|
||
console.log("[Staff Seed] Table exists and is accessible");
|
||
|
||
const mockMembers = [
|
||
{
|
||
email: "alex@aethex.dev",
|
||
full_name: "Alex Chen",
|
||
position: "Founder & CEO",
|
||
department: "Executive",
|
||
phone: "+1 (555) 000-0001",
|
||
role: "owner",
|
||
location: "San Francisco, CA",
|
||
},
|
||
{
|
||
email: "jordan@aethex.dev",
|
||
full_name: "Jordan Martinez",
|
||
position: "CTO",
|
||
department: "Engineering",
|
||
phone: "+1 (555) 000-0002",
|
||
role: "admin",
|
||
location: "New York, NY",
|
||
},
|
||
{
|
||
email: "sam@aethex.dev",
|
||
full_name: "Sam Patel",
|
||
position: "Community Manager",
|
||
department: "Community",
|
||
phone: "+1 (555) 000-0003",
|
||
role: "staff",
|
||
location: "Austin, TX",
|
||
},
|
||
{
|
||
email: "taylor@aethex.dev",
|
||
full_name: "Taylor Kim",
|
||
position: "Operations Lead",
|
||
department: "Operations",
|
||
phone: "+1 (555) 000-0004",
|
||
role: "staff",
|
||
location: "Seattle, WA",
|
||
},
|
||
{
|
||
email: "casey@aethex.dev",
|
||
full_name: "Casey Zhang",
|
||
position: "Software Engineer",
|
||
department: "Engineering",
|
||
phone: "+1 (555) 000-0005",
|
||
role: "employee",
|
||
location: "San Francisco, CA",
|
||
},
|
||
{
|
||
email: "morgan@aethex.dev",
|
||
full_name: "Morgan Lee",
|
||
position: "Designer",
|
||
department: "Design",
|
||
phone: "+1 (555) 000-0006",
|
||
role: "employee",
|
||
location: "Los Angeles, CA",
|
||
},
|
||
{
|
||
email: "alex.kim@aethex.dev",
|
||
full_name: "Alex Kim",
|
||
position: "Marketing Manager",
|
||
department: "Marketing",
|
||
phone: "+1 (555) 000-0007",
|
||
role: "staff",
|
||
location: "Boston, MA",
|
||
},
|
||
{
|
||
email: "jordan.lee@aethex.dev",
|
||
full_name: "Jordan Lee",
|
||
position: "Data Analyst",
|
||
department: "Analytics",
|
||
phone: "+1 (555) 000-0008",
|
||
role: "employee",
|
||
location: "Denver, CO",
|
||
},
|
||
];
|
||
|
||
try {
|
||
const { data, error } = await adminSupabase
|
||
.from("staff_members")
|
||
.upsert(mockMembers, { onConflict: "email" })
|
||
.select();
|
||
|
||
if (error) {
|
||
console.error("[Staff Seed Error] Supabase error:", error);
|
||
return res.status(500).json({
|
||
error: "Failed to seed staff members",
|
||
supabaseError: error,
|
||
mockMembersCount: mockMembers.length,
|
||
});
|
||
}
|
||
|
||
console.log(
|
||
"[Staff Seed Success] Inserted",
|
||
data?.length || 0,
|
||
"members",
|
||
);
|
||
|
||
const response = {
|
||
success: true,
|
||
count: data?.length || 0,
|
||
members: data || [],
|
||
};
|
||
|
||
return res.status(201).json(response);
|
||
} catch (insertError: any) {
|
||
console.error("[Staff Seed Insert Error]", insertError);
|
||
return res.status(500).json({
|
||
error: "Exception during insert",
|
||
message: insertError?.message || String(insertError),
|
||
});
|
||
}
|
||
} catch (e: any) {
|
||
console.error("[Staff Seed Exception]", e);
|
||
res.status(500).json({ error: e?.message || String(e) });
|
||
}
|
||
});
|
||
|
||
app.post("/api/staff/members", async (req, res) => {
|
||
try {
|
||
const {
|
||
user_id,
|
||
email,
|
||
full_name,
|
||
position,
|
||
department,
|
||
phone,
|
||
avatar_url,
|
||
role,
|
||
hired_date,
|
||
} = req.body || {};
|
||
|
||
if (!email || !full_name) {
|
||
return res.status(400).json({
|
||
error: "Missing required fields: email, full_name",
|
||
});
|
||
}
|
||
|
||
const { data, error } = await adminSupabase
|
||
.from("staff_members")
|
||
.insert([
|
||
{
|
||
user_id: user_id || null,
|
||
email,
|
||
full_name,
|
||
position: position || null,
|
||
department: department || null,
|
||
phone: phone || null,
|
||
avatar_url: avatar_url || null,
|
||
role: role || "employee",
|
||
hired_date: hired_date || null,
|
||
},
|
||
])
|
||
.select();
|
||
|
||
if (error) {
|
||
return res.status(500).json({
|
||
error: "Failed to create staff member",
|
||
details: error.message,
|
||
});
|
||
}
|
||
|
||
return res.status(201).json(data?.[0] || {});
|
||
} catch (e: any) {
|
||
return res.status(500).json({ error: e?.message || String(e) });
|
||
}
|
||
});
|
||
|
||
app.get("/api/staff/members-detail", async (req, res) => {
|
||
try {
|
||
const id = String(req.query.id || "");
|
||
if (!id) {
|
||
return res.status(400).json({ error: "Missing staff member ID" });
|
||
}
|
||
|
||
const { data, error } = await adminSupabase
|
||
.from("staff_members")
|
||
.select("*")
|
||
.eq("id", id)
|
||
.single();
|
||
|
||
if (error || !data) {
|
||
return res.status(404).json({ error: "Staff member not found" });
|
||
}
|
||
|
||
return res.json(data);
|
||
} catch (e: any) {
|
||
return res.status(500).json({ error: e?.message || String(e) });
|
||
}
|
||
});
|
||
|
||
app.put("/api/staff/members-detail", async (req, res) => {
|
||
try {
|
||
const id = String(req.query.id || "");
|
||
if (!id) {
|
||
return res.status(400).json({ error: "Missing staff member ID" });
|
||
}
|
||
|
||
const updates = req.body || {};
|
||
|
||
const { data, error } = await adminSupabase
|
||
.from("staff_members")
|
||
.update({
|
||
...updates,
|
||
updated_at: new Date().toISOString(),
|
||
})
|
||
.eq("id", id)
|
||
.select()
|
||
.single();
|
||
|
||
if (error) {
|
||
return res.status(500).json({
|
||
error: "Failed to update staff member",
|
||
details: error.message,
|
||
});
|
||
}
|
||
|
||
if (!data) {
|
||
return res.status(404).json({ error: "Staff member not found" });
|
||
}
|
||
|
||
return res.json(data);
|
||
} catch (e: any) {
|
||
return res.status(500).json({ error: e?.message || String(e) });
|
||
}
|
||
});
|
||
|
||
app.delete("/api/staff/members-detail", async (req, res) => {
|
||
try {
|
||
const id = String(req.query.id || "");
|
||
if (!id) {
|
||
return res.status(400).json({ error: "Missing staff member ID" });
|
||
}
|
||
|
||
const { error } = await adminSupabase
|
||
.from("staff_members")
|
||
.delete()
|
||
.eq("id", id);
|
||
|
||
if (error) {
|
||
return res.status(500).json({
|
||
error: "Failed to delete staff member",
|
||
details: error.message,
|
||
});
|
||
}
|
||
|
||
return res.json({ success: true, id });
|
||
} catch (e: any) {
|
||
return res.status(500).json({ error: e?.message || String(e) });
|
||
}
|
||
});
|
||
|
||
// Track device login and send security alert
|
||
app.post("/api/auth/login-device", async (req, res) => {
|
||
try {
|
||
const { user_id, device_name, ip_address, user_agent } = req.body;
|
||
|
||
if (!user_id) {
|
||
return res.status(400).json({ error: "user_id required" });
|
||
}
|
||
|
||
// Store device login
|
||
const { data: sessionData } = await adminSupabase
|
||
.from("game_sessions")
|
||
.insert({
|
||
user_id,
|
||
device_id: device_name || "Unknown Device",
|
||
ip_address: ip_address || null,
|
||
user_agent: user_agent || null,
|
||
session_token: randomUUID(),
|
||
})
|
||
.select()
|
||
.single();
|
||
|
||
// Check if this is a new device (security alert)
|
||
const { data: previousSessions } = await adminSupabase
|
||
.from("game_sessions")
|
||
.select("device_id")
|
||
.eq("user_id", user_id)
|
||
.neq("device_id", device_name || "Unknown Device");
|
||
|
||
// If new device, send security notification
|
||
if (!previousSessions || previousSessions.length === 0) {
|
||
try {
|
||
await adminSupabase.from("notifications").insert({
|
||
user_id,
|
||
type: "warning",
|
||
title: "🔐 New device login detected",
|
||
message: `New login from ${device_name || "Unknown device"} at ${ip_address || "Unknown location"}. If this wasn't you, please secure your account.`,
|
||
});
|
||
} catch (notifError) {
|
||
console.warn("Failed to create security notification:", notifError);
|
||
}
|
||
}
|
||
|
||
return res.status(201).json(sessionData);
|
||
} catch (e: any) {
|
||
console.error("[Auth API] Error tracking login:", e?.message);
|
||
return res.status(500).json({ error: "Failed to track login" });
|
||
}
|
||
});
|
||
|
||
// Admin endpoint to delete user account
|
||
app.delete("/api/admin/users/delete", async (req, res) => {
|
||
try {
|
||
const adminToken =
|
||
req.headers.authorization?.replace("Bearer ", "") || "";
|
||
|
||
if (adminToken !== process.env.DISCORD_ADMIN_REGISTER_TOKEN) {
|
||
return res.status(401).json({ error: "Unauthorized" });
|
||
}
|
||
|
||
const { email } = req.body;
|
||
|
||
if (!email) {
|
||
return res.status(400).json({ error: "Missing email parameter" });
|
||
}
|
||
|
||
// Get the user by email
|
||
const { data: profile, error: profileError } = await adminSupabase
|
||
.from("user_profiles")
|
||
.select("user_id, email")
|
||
.eq("email", email)
|
||
.single();
|
||
|
||
if (profileError || !profile) {
|
||
return res.status(404).json({
|
||
error: "User not found",
|
||
details: profileError?.message,
|
||
});
|
||
}
|
||
|
||
const userId = profile.user_id;
|
||
|
||
// Delete from various tables in order
|
||
await adminSupabase
|
||
.from("achievements_earned")
|
||
.delete()
|
||
.eq("user_id", userId);
|
||
|
||
await adminSupabase.from("applications").delete().eq("user_id", userId);
|
||
|
||
await adminSupabase
|
||
.from("creator_profiles")
|
||
.delete()
|
||
.eq("user_id", userId);
|
||
|
||
await adminSupabase.from("projects").delete().eq("user_id", userId);
|
||
|
||
await adminSupabase.from("social_posts").delete().eq("user_id", userId);
|
||
|
||
await adminSupabase
|
||
.from("user_email_links")
|
||
.delete()
|
||
.eq("user_id", userId);
|
||
|
||
await adminSupabase
|
||
.from("discord_links")
|
||
.delete()
|
||
.eq("user_id", userId);
|
||
|
||
await adminSupabase.from("web3_wallets").delete().eq("user_id", userId);
|
||
|
||
// Delete user profile
|
||
const { error: profileDeleteError } = await adminSupabase
|
||
.from("user_profiles")
|
||
.delete()
|
||
.eq("user_id", userId);
|
||
|
||
if (profileDeleteError) {
|
||
return res.status(500).json({
|
||
error: "Failed to delete user profile",
|
||
details: profileDeleteError.message,
|
||
});
|
||
}
|
||
|
||
// Delete from Supabase auth
|
||
try {
|
||
await (adminSupabase.auth.admin as any).deleteUser(userId);
|
||
} catch (authError: any) {
|
||
console.warn("Auth deletion warning:", authError?.message);
|
||
}
|
||
|
||
return res.json({
|
||
success: true,
|
||
message: `User account ${email} has been successfully deleted`,
|
||
userId,
|
||
});
|
||
} catch (e: any) {
|
||
console.error("[Admin API] Error deleting user:", e?.message);
|
||
return res.status(500).json({
|
||
error: "Internal server error",
|
||
message: e?.message,
|
||
});
|
||
}
|
||
});
|
||
} catch (e) {
|
||
console.warn("Admin API not initialized:", e);
|
||
}
|
||
|
||
// Blog API routes
|
||
app.get("/api/blog", blogIndexHandler);
|
||
app.get("/api/blog/:slug", (req: express.Request, res: express.Response) => {
|
||
return blogSlugHandler(req, res);
|
||
});
|
||
|
||
return app;
|
||
}
|