3284 lines
104 KiB
TypeScript
3284 lines
104 KiB
TypeScript
import "dotenv/config";
|
|
import express from "express";
|
|
import cors from "cors";
|
|
import { adminSupabase } from "./supabase";
|
|
import { emailService } from "./email";
|
|
import { randomUUID, createHash, createVerify } from "crypto";
|
|
|
|
// Discord Interactions Handler
|
|
const handleDiscordInteractions = (
|
|
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,
|
|
},
|
|
});
|
|
}
|
|
|
|
// 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`,
|
|
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();
|
|
});
|
|
|
|
// Example API routes
|
|
app.get("/api/ping", (_req, res) => {
|
|
const ping = process.env.PING_MESSAGE ?? "ping";
|
|
res.json({ message: ping });
|
|
});
|
|
|
|
// 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: callback handler
|
|
app.post("/api/discord/oauth/callback", async (req, res) => {
|
|
const { code, state } = (req.body || {}) as {
|
|
code?: string;
|
|
state?: string;
|
|
};
|
|
|
|
if (!code) {
|
|
return res
|
|
.status(400)
|
|
.json({ error: "Authorization code is required" });
|
|
}
|
|
|
|
try {
|
|
const clientId =
|
|
process.env.VITE_DISCORD_CLIENT_ID || "578971245454950421";
|
|
const clientSecret = process.env.DISCORD_CLIENT_SECRET;
|
|
const redirectUri =
|
|
process.env.DISCORD_REDIRECT_URI ||
|
|
`${process.env.PUBLIC_BASE_URL || process.env.SITE_URL || "http://localhost:5173"}/discord/callback`;
|
|
|
|
if (!clientSecret) {
|
|
console.warn(
|
|
"[Discord OAuth] DISCORD_CLIENT_SECRET not configured, skipping token exchange",
|
|
);
|
|
return res.json({
|
|
ok: true,
|
|
access_token: null,
|
|
message: "Discord auth configured for Activity context only",
|
|
});
|
|
}
|
|
|
|
// 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,
|
|
}),
|
|
},
|
|
);
|
|
|
|
if (!tokenResponse.ok) {
|
|
const errorData = await tokenResponse.text();
|
|
console.error("[Discord OAuth] Token exchange failed:", {
|
|
status: tokenResponse.status,
|
|
error: errorData,
|
|
});
|
|
return res.status(400).json({
|
|
error: "Failed to exchange authorization code",
|
|
});
|
|
}
|
|
|
|
const tokenData = await tokenResponse.json();
|
|
|
|
// Get Discord user information
|
|
const userResponse = await fetch("https://discord.com/api/users/@me", {
|
|
headers: {
|
|
Authorization: `Bearer ${tokenData.access_token}`,
|
|
},
|
|
});
|
|
|
|
if (!userResponse.ok) {
|
|
return res.status(400).json({
|
|
error: "Failed to retrieve Discord user information",
|
|
});
|
|
}
|
|
|
|
const discordUser = await userResponse.json();
|
|
|
|
// Optionally: create or update Supabase user linked to Discord account
|
|
if (adminSupabase && discordUser.id) {
|
|
try {
|
|
// Check if user with Discord ID exists
|
|
const { data: existingUser } = await adminSupabase
|
|
.from("user_profiles")
|
|
.select("id")
|
|
.eq("discord_id", discordUser.id)
|
|
.maybeSingle();
|
|
|
|
if (!existingUser && discordUser.email) {
|
|
// Attempt to find by email
|
|
const { data: userByEmail } = await adminSupabase
|
|
.from("user_profiles")
|
|
.select("id")
|
|
.eq("email", discordUser.email)
|
|
.maybeSingle();
|
|
|
|
if (userByEmail) {
|
|
// Update existing user with Discord ID
|
|
await adminSupabase
|
|
.from("user_profiles")
|
|
.update({ discord_id: discordUser.id })
|
|
.eq("id", userByEmail.id);
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.warn("[Discord OAuth] Failed to link Discord ID:", err);
|
|
}
|
|
}
|
|
|
|
return res.json({
|
|
ok: true,
|
|
access_token: tokenData.access_token,
|
|
discord_user: {
|
|
id: discordUser.id,
|
|
username: discordUser.username,
|
|
avatar: discordUser.avatar,
|
|
email: discordUser.email,
|
|
},
|
|
});
|
|
} catch (e: any) {
|
|
console.error("[Discord OAuth] Error:", e);
|
|
return res.status(500).json({
|
|
error: e?.message || "Internal server error during Discord OAuth",
|
|
});
|
|
}
|
|
});
|
|
|
|
// 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) => {
|
|
const payload = req.body || {};
|
|
|
|
// Validation
|
|
if (!payload.author_id) {
|
|
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" });
|
|
}
|
|
|
|
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) });
|
|
}
|
|
});
|
|
|
|
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) });
|
|
}
|
|
});
|
|
|
|
// Blog endpoints (Supabase-backed)
|
|
app.get("/api/blog", async (req, res) => {
|
|
const limit = Math.max(1, Math.min(50, Number(req.query.limit) || 12));
|
|
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",
|
|
)
|
|
.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) 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/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) return res.status(404).json({ error: error.message });
|
|
res.json(data || null);
|
|
} catch (e: any) {
|
|
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", 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("project_applications")
|
|
.select(`*, projects!inner(id, title, user_id)`)
|
|
.eq("projects.user_id", owner)
|
|
.order("created_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) });
|
|
}
|
|
});
|
|
|
|
// 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 {
|
|
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);
|
|
}
|
|
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 {
|
|
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);
|
|
}
|
|
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/:username", async (req, res) => {
|
|
try {
|
|
const username = String(req.params.username || "").trim();
|
|
if (!username) {
|
|
return res.status(400).json({ error: "username required" });
|
|
}
|
|
|
|
const { data: creator, error } = await adminSupabase
|
|
.from("aethex_creators")
|
|
.select(
|
|
`
|
|
id,
|
|
username,
|
|
bio,
|
|
skills,
|
|
avatar_url,
|
|
experience_level,
|
|
arm_affiliations,
|
|
primary_arm,
|
|
created_at,
|
|
updated_at
|
|
`,
|
|
)
|
|
.eq("username", username)
|
|
.eq("is_discoverable", true)
|
|
.single();
|
|
|
|
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" });
|
|
}
|
|
});
|
|
|
|
// 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;
|
|
|
|
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;
|
|
|
|
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" });
|
|
}
|
|
});
|
|
} catch (e) {
|
|
console.warn("Admin API not initialized:", e);
|
|
}
|
|
|
|
return app;
|
|
}
|