diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts index b4f6d07..0293ecb 100644 --- a/server/src/db/schema.ts +++ b/server/src/db/schema.ts @@ -18,6 +18,20 @@ export const users = pgTable("users", { lastLoginAt: timestamp("last_login_at").defaultNow().notNull(), }); +export const feedbackEntries = pgTable( + "feedback_entries", + { + id: serial("id").primaryKey(), + userId: integer("user_id") + .notNull() + .references(() => users.id), + message: text("message").notNull(), + images: jsonb("images"), + createdAt: timestamp("created_at").defaultNow().notNull(), + }, + (table) => [index("idx_feedback_entries_user").on(table.userId)], +); + export const metricEvents = pgTable( "metric_events", { diff --git a/server/src/index.ts b/server/src/index.ts index 937c395..1c8b59b 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -6,6 +6,7 @@ import { config } from "./lib/config.js"; import { auth } from "./routes/auth.js"; import { health } from "./routes/health.js"; import { metrics } from "./routes/metrics.js"; +import { feedback } from "./routes/feedback.js"; const app = new Hono(); @@ -15,6 +16,7 @@ app.use("*", cors()); app.route("/", auth); app.route("/", health); app.route("/", metrics); +app.route("/", feedback); console.log(`SUSTN auth server starting on port ${config.port}`); diff --git a/server/src/routes/feedback.ts b/server/src/routes/feedback.ts new file mode 100644 index 0000000..6c2abdd --- /dev/null +++ b/server/src/routes/feedback.ts @@ -0,0 +1,63 @@ +import { Hono } from "hono"; +import { eq } from "drizzle-orm"; +import { db } from "../db/index.js"; +import { users, feedbackEntries } from "../db/schema.js"; +import { fetchGitHubUser } from "../lib/github.js"; + +const feedback = new Hono(); + +async function resolveUserId(token: string): Promise { + try { + const ghUser = await fetchGitHubUser(token); + const rows = await db + .select({ id: users.id }) + .from(users) + .where(eq(users.githubId, ghUser.id)) + .limit(1); + return rows.length > 0 ? rows[0].id : null; + } catch { + return null; + } +} + +interface FeedbackImage { + name: string; + data: string; +} + +interface FeedbackBody { + message: string; + images?: FeedbackImage[]; +} + +feedback.post("/feedback", async (c) => { + const authHeader = c.req.header("Authorization"); + if (!authHeader?.startsWith("Bearer ")) { + return c.json({ error: "Unauthorized" }, 401); + } + + const token = authHeader.slice(7); + const userId = await resolveUserId(token); + if (!userId) { + return c.json({ error: "Unauthorized" }, 401); + } + + const body = await c.req.json(); + if (!body.message || typeof body.message !== "string" || !body.message.trim()) { + return c.json({ error: "Message is required" }, 400); + } + + // Cap images to 5 and message length to 10,000 chars + const message = body.message.trim().slice(0, 10_000); + const images = Array.isArray(body.images) ? body.images.slice(0, 5) : null; + + await db.insert(feedbackEntries).values({ + userId, + message, + images, + }); + + return c.json({ success: true }); +}); + +export { feedback };