- Fix docker-compose to read GATEWAY_WS_URL (was VITE_WS_URL, never set) - Fix gateway-relay.ts default to ws.hammer.donovankelly.xyz - Fix Elysia TS errors in error handlers (cast to any) - Add thinking/typing indicator in chat (bouncing dots) - Add message timestamps (tap to show) - Add thread renaming (double-click thread name) - Auto-resize chat input textarea
159 lines
4.7 KiB
TypeScript
159 lines
4.7 KiB
TypeScript
import { Elysia } from "elysia";
|
|
import { cors } from "@elysiajs/cors";
|
|
import { taskRoutes } from "./routes/tasks";
|
|
import { adminRoutes } from "./routes/admin";
|
|
import { projectRoutes } from "./routes/projects";
|
|
import { chatRoutes } from "./routes/chat";
|
|
import { auth } from "./lib/auth";
|
|
import { db } from "./db";
|
|
import { tasks, users } from "./db/schema";
|
|
import { isNull, asc, sql, eq } from "drizzle-orm";
|
|
|
|
const PORT = process.env.PORT || 3100;
|
|
|
|
// Backfill task numbers for existing tasks that don't have one
|
|
async function backfillTaskNumbers() {
|
|
const unnumbered = await db
|
|
.select()
|
|
.from(tasks)
|
|
.where(isNull(tasks.taskNumber))
|
|
.orderBy(asc(tasks.createdAt));
|
|
|
|
if (unnumbered.length === 0) return;
|
|
|
|
const maxNum = await db
|
|
.select({ max: sql<number>`COALESCE(MAX(${tasks.taskNumber}), 0)` })
|
|
.from(tasks);
|
|
let next = (maxNum[0]?.max ?? 0) + 1;
|
|
|
|
for (const task of unnumbered) {
|
|
await db
|
|
.update(tasks)
|
|
.set({ taskNumber: next++ })
|
|
.where(sql`${tasks.id} = ${task.id}`);
|
|
}
|
|
console.log(`Backfilled ${unnumbered.length} task numbers (${next - unnumbered.length} to ${next - 1})`);
|
|
}
|
|
|
|
backfillTaskNumbers().catch(console.error);
|
|
|
|
// Ensure donovan@donovankelly.xyz is admin
|
|
async function ensureAdmin() {
|
|
const adminEmail = "donovan@donovankelly.xyz";
|
|
const result = await db
|
|
.update(users)
|
|
.set({ role: "admin" })
|
|
.where(eq(users.email, adminEmail))
|
|
.returning({ id: users.id, email: users.email, role: users.role });
|
|
if (result.length) {
|
|
console.log(`Admin role ensured for ${adminEmail}`);
|
|
}
|
|
}
|
|
ensureAdmin().catch(console.error);
|
|
|
|
const app = new Elysia()
|
|
.use(
|
|
cors({
|
|
origin: ["https://dash.donovankelly.xyz", "https://queue.donovankelly.xyz", "http://localhost:5173"],
|
|
credentials: true,
|
|
allowedHeaders: ["Content-Type", "Authorization", "Cookie"],
|
|
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
|
})
|
|
)
|
|
|
|
// Mount BetterAuth handler
|
|
.all("/api/auth/*", async ({ request }) => {
|
|
return auth.handler(request);
|
|
})
|
|
|
|
// Invite route - create a user (bearer token or session auth)
|
|
.post("/api/invite", async ({ request, headers, body }) => {
|
|
const bearerToken = process.env.API_BEARER_TOKEN || "hammer-dev-token";
|
|
const authHeader = headers["authorization"];
|
|
|
|
// Check bearer token first
|
|
let authorized = authHeader === `Bearer ${bearerToken}`;
|
|
|
|
// If no bearer token, check session
|
|
if (!authorized) {
|
|
const session = await auth.api.getSession({ headers: request.headers });
|
|
authorized = !!session;
|
|
}
|
|
|
|
if (!authorized) {
|
|
return new Response(JSON.stringify({ error: "Unauthorized" }), {
|
|
status: 401,
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
}
|
|
|
|
const { email, password, name } = body as {
|
|
email: string;
|
|
password: string;
|
|
name: string;
|
|
};
|
|
if (!email || !password || !name) {
|
|
return new Response(
|
|
JSON.stringify({ error: "email, password, and name are required" }),
|
|
{ status: 400, headers: { "Content-Type": "application/json" } }
|
|
);
|
|
}
|
|
|
|
try {
|
|
const user = await auth.api.signUpEmail({
|
|
body: { email, password, name },
|
|
});
|
|
return new Response(JSON.stringify({ success: true, user }), {
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
} catch (e: any) {
|
|
return new Response(
|
|
JSON.stringify({ error: e.message || "Failed to create user" }),
|
|
{ status: 400, headers: { "Content-Type": "application/json" } }
|
|
);
|
|
}
|
|
})
|
|
|
|
.use(taskRoutes)
|
|
.use(projectRoutes)
|
|
.use(adminRoutes)
|
|
.use(chatRoutes)
|
|
|
|
// Current user info (role, etc.)
|
|
.get("/api/me", async ({ request }) => {
|
|
try {
|
|
const session = await auth.api.getSession({ headers: request.headers });
|
|
if (!session?.user) return { authenticated: false };
|
|
return {
|
|
authenticated: true,
|
|
user: {
|
|
id: session.user.id,
|
|
name: session.user.name,
|
|
email: session.user.email,
|
|
role: (session.user as any).role || "user",
|
|
},
|
|
};
|
|
} catch {
|
|
return { authenticated: false };
|
|
}
|
|
})
|
|
|
|
.get("/health", () => ({ status: "ok", service: "hammer-queue" }))
|
|
.onError(({ error, set }) => {
|
|
const msg = (error as any)?.message || String(error);
|
|
if (msg === "Unauthorized") {
|
|
set.status = 401;
|
|
return { error: "Unauthorized" };
|
|
}
|
|
if (msg === "Task not found") {
|
|
set.status = 404;
|
|
return { error: "Task not found" };
|
|
}
|
|
console.error("Unhandled error:", msg);
|
|
set.status = 500;
|
|
return { error: "Internal server error" };
|
|
})
|
|
.listen(PORT);
|
|
|
|
console.log(`🔨 Hammer Queue API running on port ${PORT}`);
|