--- /dev/null
+// deno-lint-ignore-file
+/** @jsxImportSource https://esm.sh/hono@4.7/jsx */
+import { Hono } from "https://esm.sh/hono@4.7";
+import {
+ deleteCookie,
+ getCookie,
+ setCookie,
+} from "https://esm.sh/hono@4.7/cookie";
+import * as bcrypt from "https://deno.land/x/bcrypt@v0.4.1/mod.ts";
+
+// ============================================================================
+// PHASE 1: CORE STRUCTURE & FOUNDATION
+// ============================================================================
+
+// ============================================================================
+// CONFIGURATION & CONSTANTS
+// ============================================================================
+
+const CONFIG = {
+ SESSION_EXPIRY_DAYS: 7,
+ SESSION_COOKIE_NAME: "ama_session",
+ DATABASE_DIRECTORY: "./storage",
+ WEBHOOK_RETRY_ATTEMPTS: 3,
+ WEBHOOK_RETRY_DELAYS: [0, 5000, 25000], // milliseconds
+ CHARACTER_LIMITS: {
+ QUESTION: 500,
+ ANSWER: 5000,
+ WEBHOOK_URL: 500,
+ },
+ LIKE_COOLDOWN_HOURS: 24,
+ ACCENT_COLOR: "#3B82F6", // Soft blue
+} as const;
+
+const CRON_SCHEDULES = {
+ CLEANUP_SESSIONS: "0 3 * * *", // Daily at 3 AM
+ CLEANUP_FAILED_WEBHOOKS: "0 4 * * 1", // Weekly Sunday at 4 AM
+} as const;
+
+// ============================================================================
+// TYPE DEFINITIONS
+// ============================================================================
+
+interface User {
+ username: string;
+ passwordHash: string;
+}
+
+interface Session {
+ username: string;
+ createdAt: number;
+ expiresAt: number;
+}
+
+interface Question {
+ id: string;
+ question: string;
+ questionLikes: number;
+ answer: string | null;
+ answerLikes: number;
+ createdAt: number;
+ answeredAt: number | null;
+ questionLiked?: boolean;
+ answerLiked?: boolean;
+}
+
+interface FailedWebhook {
+ questionId: string;
+ question: string;
+ attempts: number;
+ lastError: string;
+ createdAt: number;
+}
+
+// ============================================================================
+// DATABASE LAYER (Repository Pattern)
+// ============================================================================
+
+class KVRepository {
+ private kv: Deno.Kv;
+
+ constructor(kv: Deno.Kv) {
+ this.kv = kv;
+ }
+
+ // User operations
+ async getUserByUsername(username: string): Promise<User | null> {
+ const result = await this.kv.get<User>(["users", username]);
+ return result.value;
+ }
+
+ // Session operations
+ async createSession(sessionId: string, session: Session): Promise<void> {
+ await this.kv.set(["sessions", sessionId], session);
+ }
+
+ async getSession(sessionId: string): Promise<Session | null> {
+ const result = await this.kv.get<Session>(["sessions", sessionId]);
+ return result.value;
+ }
+
+ async deleteSession(sessionId: string): Promise<void> {
+ await this.kv.delete(["sessions", sessionId]);
+ }
+
+ async deleteExpiredSessions(): Promise<number> {
+ const now = Date.now();
+ let deletedCount = 0;
+
+ const sessions = this.kv.list<Session>({ prefix: ["sessions"] });
+ for await (const entry of sessions) {
+ if (entry.value.expiresAt < now) {
+ await this.kv.delete(entry.key);
+ deletedCount++;
+ }
+ }
+
+ return deletedCount;
+ }
+
+ // Question operations
+ async createQuestion(question: Question): Promise<void> {
+ const engagement = this.calculateEngagement(question);
+
+ await this.kv.atomic()
+ .set(["questions", question.id], question)
+ .set([
+ "questions_by_engagement",
+ engagement,
+ question.createdAt,
+ question.id,
+ ], question.id)
+ .commit();
+ }
+
+ async getQuestion(questionId: string): Promise<Question | null> {
+ const result = await this.kv.get<Question>(["questions", questionId]);
+ return result.value;
+ }
+
+ async getAllQuestionsSortedByEngagement(): Promise<Question[]> {
+ const questions: Question[] = [];
+
+ // List in reverse order (highest engagement first, then newest)
+ const entries = this.kv.list<string>({
+ prefix: ["questions_by_engagement"],
+ });
+
+ for await (const entry of entries) {
+ const questionId = entry.value;
+ const question = await this.getQuestion(questionId);
+ if (question) {
+ questions.push(question);
+ }
+ }
+
+ return questions;
+ }
+
+ async updateQuestionWithAnswer(
+ questionId: string,
+ answer: string,
+ ): Promise<Question | null> {
+ const question = await this.getQuestion(questionId);
+ if (!question) return null;
+
+ const oldEngagement = this.calculateEngagement(question);
+
+ question.answer = answer;
+ question.answeredAt = Date.now();
+
+ const newEngagement = this.calculateEngagement(question);
+
+ await this.kv.atomic()
+ .set(["questions", questionId], question)
+ .delete([
+ "questions_by_engagement",
+ oldEngagement,
+ question.createdAt,
+ questionId,
+ ])
+ .set([
+ "questions_by_engagement",
+ newEngagement,
+ question.createdAt,
+ questionId,
+ ], questionId)
+ .commit();
+
+ return question;
+ }
+
+ async incrementQuestionLikes(questionId: string): Promise<Question | null> {
+ const question = await this.getQuestion(questionId);
+ if (!question) return null;
+
+ const oldEngagement = this.calculateEngagement(question);
+
+ question.questionLikes++;
+
+ const newEngagement = this.calculateEngagement(question);
+
+ await this.kv.atomic()
+ .set(["questions", questionId], question)
+ .delete([
+ "questions_by_engagement",
+ oldEngagement,
+ question.createdAt,
+ questionId,
+ ])
+ .set([
+ "questions_by_engagement",
+ newEngagement,
+ question.createdAt,
+ questionId,
+ ], questionId)
+ .commit();
+
+ return question;
+ }
+
+ async incrementAnswerLikes(questionId: string): Promise<Question | null> {
+ const question = await this.getQuestion(questionId);
+ if (!question || !question.answer) return null;
+
+ const oldEngagement = this.calculateEngagement(question);
+
+ question.answerLikes++;
+
+ const newEngagement = this.calculateEngagement(question);
+
+ await this.kv.atomic()
+ .set(["questions", questionId], question)
+ .delete([
+ "questions_by_engagement",
+ oldEngagement,
+ question.createdAt,
+ questionId,
+ ])
+ .set([
+ "questions_by_engagement",
+ newEngagement,
+ question.createdAt,
+ questionId,
+ ], questionId)
+ .commit();
+
+ return question;
+ }
+
+ async decrementQuestionLikes(questionId: string): Promise<Question | null> {
+ const question = await this.getQuestion(questionId);
+ if (!question || question.questionLikes <= 0) return null;
+
+ const oldEngagement = this.calculateEngagement(question);
+
+ question.questionLikes--;
+
+ const newEngagement = this.calculateEngagement(question);
+
+ await this.kv.atomic()
+ .set(["questions", questionId], question)
+ .delete([
+ "questions_by_engagement",
+ oldEngagement,
+ question.createdAt,
+ questionId,
+ ])
+ .set([
+ "questions_by_engagement",
+ newEngagement,
+ question.createdAt,
+ questionId,
+ ], questionId)
+ .commit();
+
+ return question;
+ }
+
+ async decrementAnswerLikes(questionId: string): Promise<Question | null> {
+ const question = await this.getQuestion(questionId);
+ if (!question || !question.answer || question.answerLikes <= 0) return null;
+
+ const oldEngagement = this.calculateEngagement(question);
+
+ question.answerLikes--;
+
+ const newEngagement = this.calculateEngagement(question);
+
+ await this.kv.atomic()
+ .set(["questions", questionId], question)
+ .delete([
+ "questions_by_engagement",
+ oldEngagement,
+ question.createdAt,
+ questionId,
+ ])
+ .set([
+ "questions_by_engagement",
+ newEngagement,
+ question.createdAt,
+ questionId,
+ ], questionId)
+ .commit();
+
+ return question;
+ }
+
+ async deleteQuestionLike(questionId: string, ipHash: string): Promise<void> {
+ await this.kv.delete(["question_likes", questionId, ipHash]);
+ }
+
+ async deleteAnswerLike(questionId: string, ipHash: string): Promise<void> {
+ await this.kv.delete(["answer_likes", questionId, ipHash]);
+ }
+
+ async bulkDeleteQuestions(questionIds: string[]): Promise<void> {
+ for (const questionId of questionIds) {
+ const question = await this.getQuestion(questionId);
+ if (question) {
+ const engagement = this.calculateEngagement(question);
+
+ await this.kv.atomic()
+ .delete(["questions", questionId])
+ .delete([
+ "questions_by_engagement",
+ engagement,
+ question.createdAt,
+ questionId,
+ ])
+ .commit();
+ }
+ }
+ }
+
+ // Like tracking operations
+ async hasUserLikedQuestion(
+ questionId: string,
+ ipHash: string,
+ ): Promise<boolean> {
+ const result = await this.kv.get(["question_likes", questionId, ipHash]);
+
+ if (!result.value) return false;
+
+ const likedAt = result.value as number;
+ const cooldownMs = CONFIG.LIKE_COOLDOWN_HOURS * 60 * 60 * 1000;
+
+ return Date.now() - likedAt < cooldownMs;
+ }
+
+ async hasUserLikedAnswer(
+ questionId: string,
+ ipHash: string,
+ ): Promise<boolean> {
+ const result = await this.kv.get(["answer_likes", questionId, ipHash]);
+
+ if (!result.value) return false;
+
+ const likedAt = result.value as number;
+ const cooldownMs = CONFIG.LIKE_COOLDOWN_HOURS * 60 * 60 * 1000;
+
+ return Date.now() - likedAt < cooldownMs;
+ }
+
+ async recordQuestionLike(questionId: string, ipHash: string): Promise<void> {
+ await this.kv.set(["question_likes", questionId, ipHash], Date.now());
+ }
+
+ async recordAnswerLike(questionId: string, ipHash: string): Promise<void> {
+ await this.kv.set(["answer_likes", questionId, ipHash], Date.now());
+ }
+
+ // Config operations
+ async getWebhookUrl(): Promise<string | null> {
+ const result = await this.kv.get<string>(["config", "webhook_url"]);
+ return result.value;
+ }
+
+ async setWebhookUrl(url: string): Promise<void> {
+ await this.kv.set(["config", "webhook_url"], url);
+ }
+
+ // Failed webhook operations
+ async saveFailedWebhook(webhook: FailedWebhook): Promise<void> {
+ await this.kv.set(
+ ["failed_webhooks", webhook.createdAt, webhook.questionId],
+ webhook,
+ );
+ }
+
+ async getAllFailedWebhooks(): Promise<FailedWebhook[]> {
+ const webhooks: FailedWebhook[] = [];
+
+ const entries = this.kv.list<FailedWebhook>({
+ prefix: ["failed_webhooks"],
+ });
+
+ for await (const entry of entries) {
+ webhooks.push(entry.value);
+ }
+
+ return webhooks;
+ }
+
+ async deleteFailedWebhook(
+ timestamp: number,
+ questionId: string,
+ ): Promise<void> {
+ await this.kv.delete(["failed_webhooks", timestamp, questionId]);
+ }
+
+ async deleteOldFailedWebhooks(daysOld: number): Promise<number> {
+ const cutoffTime = Date.now() - (daysOld * 24 * 60 * 60 * 1000);
+ let deletedCount = 0;
+
+ const webhooks = this.kv.list<FailedWebhook>({
+ prefix: ["failed_webhooks"],
+ });
+ for await (const entry of webhooks) {
+ if (entry.value.createdAt < cutoffTime) {
+ await this.kv.delete(entry.key);
+ deletedCount++;
+ }
+ }
+
+ return deletedCount;
+ }
+
+ // Helper: Calculate engagement score for sorting
+ private calculateEngagement(question: Question): number {
+ // Weight answer likes slightly more (1.5x) to promote quality content
+ // Multiply by 10 to avoid floating point issues with KV keys
+ // Result: questionLikes * 10 + answerLikes * 15
+ return (question.questionLikes * 10) + (question.answerLikes * 15);
+ }
+}
+
+// ============================================================================
+// SERVICES LAYER (Business Logic)
+// ============================================================================
+
+class AuthenticationService {
+ constructor(private repository: KVRepository) {}
+
+ async authenticateUser(username: string, password: string): Promise<boolean> {
+ const user = await this.repository.getUserByUsername(username);
+
+ if (!user) return false;
+
+ return await bcrypt.compare(password, user.passwordHash);
+ }
+
+ async createUserSession(username: string): Promise<string> {
+ const sessionId = crypto.randomUUID();
+ const now = Date.now();
+ const expiryMs = CONFIG.SESSION_EXPIRY_DAYS * 24 * 60 * 60 * 1000;
+
+ const session: Session = {
+ username,
+ createdAt: now,
+ expiresAt: now + expiryMs,
+ };
+
+ await this.repository.createSession(sessionId, session);
+
+ return sessionId;
+ }
+
+ async validateSession(sessionId: string): Promise<string | null> {
+ const session = await this.repository.getSession(sessionId);
+
+ if (!session) return null;
+
+ if (session.expiresAt < Date.now()) {
+ await this.repository.deleteSession(sessionId);
+ return null;
+ }
+
+ return session.username;
+ }
+
+ async destroySession(sessionId: string): Promise<void> {
+ await this.repository.deleteSession(sessionId);
+ }
+}
+
+class QuestionService {
+ constructor(private repository: KVRepository) {}
+
+ async createQuestion(questionText: string): Promise<Question> {
+ this.validateQuestionLength(questionText);
+
+ const question: Question = {
+ id: crypto.randomUUID(),
+ question: questionText.trim(),
+ questionLikes: 0,
+ answer: null,
+ answerLikes: 0,
+ createdAt: Date.now(),
+ answeredAt: null,
+ };
+
+ await this.repository.createQuestion(question);
+
+ return question;
+ }
+
+ async getAllQuestions(): Promise<Question[]> {
+ return await this.repository.getAllQuestionsSortedByEngagement();
+ }
+
+ async getQuestionById(questionId: string): Promise<Question | null> {
+ return await this.repository.getQuestion(questionId);
+ }
+
+ async answerQuestion(
+ questionId: string,
+ answerText: string,
+ ): Promise<Question | null> {
+ this.validateAnswerLength(answerText);
+
+ return await this.repository.updateQuestionWithAnswer(
+ questionId,
+ answerText.trim(),
+ );
+ }
+
+ async deleteQuestions(questionIds: string[]): Promise<void> {
+ await this.repository.bulkDeleteQuestions(questionIds);
+ }
+
+ private validateQuestionLength(text: string): void {
+ if (text.length > CONFIG.CHARACTER_LIMITS.QUESTION) {
+ throw new Error(
+ `Question exceeds maximum length of ${CONFIG.CHARACTER_LIMITS.QUESTION} characters`,
+ );
+ }
+ }
+
+ private validateAnswerLength(text: string): void {
+ if (text.length > CONFIG.CHARACTER_LIMITS.ANSWER) {
+ throw new Error(
+ `Answer exceeds maximum length of ${CONFIG.CHARACTER_LIMITS.ANSWER} characters`,
+ );
+ }
+ }
+}
+
+class LikeService {
+ constructor(private repository: KVRepository) {}
+
+ async toggleQuestionLike(
+ questionId: string,
+ ipAddress: string,
+ ): Promise<{ question: Question; liked: boolean } | null> {
+ const ipHash = await this.hashIpAddress(ipAddress);
+
+ const hasLiked = await this.repository.hasUserLikedQuestion(
+ questionId,
+ ipHash,
+ );
+
+ if (hasLiked) {
+ // Unlike: remove like and decrement count
+ await this.repository.deleteQuestionLike(questionId, ipHash);
+ const question = await this.repository.decrementQuestionLikes(questionId);
+ return question ? { question, liked: false } : null;
+ } else {
+ // Like: add like and increment count
+ await this.repository.recordQuestionLike(questionId, ipHash);
+ const question = await this.repository.incrementQuestionLikes(questionId);
+ return question ? { question, liked: true } : null;
+ }
+ }
+
+ async toggleAnswerLike(
+ questionId: string,
+ ipAddress: string,
+ ): Promise<{ question: Question; liked: boolean } | null> {
+ const question = await this.repository.getQuestion(questionId);
+
+ if (!question?.answer) {
+ return null;
+ }
+
+ const ipHash = await this.hashIpAddress(ipAddress);
+
+ const hasLiked = await this.repository.hasUserLikedAnswer(
+ questionId,
+ ipHash,
+ );
+
+ if (hasLiked) {
+ // Unlike: remove like and decrement count
+ await this.repository.deleteAnswerLike(questionId, ipHash);
+ const updatedQuestion = await this.repository.decrementAnswerLikes(
+ questionId,
+ );
+ return updatedQuestion
+ ? { question: updatedQuestion, liked: false }
+ : null;
+ } else {
+ // Like: add like and increment count
+ await this.repository.recordAnswerLike(questionId, ipHash);
+ const updatedQuestion = await this.repository.incrementAnswerLikes(
+ questionId,
+ );
+ return updatedQuestion
+ ? { question: updatedQuestion, liked: true }
+ : null;
+ }
+ }
+
+ async checkQuestionLikeStatus(
+ questionId: string,
+ ipAddress: string,
+ ): Promise<boolean> {
+ const ipHash = await this.hashIpAddress(ipAddress);
+ return await this.repository.hasUserLikedQuestion(questionId, ipHash);
+ }
+
+ async checkAnswerLikeStatus(
+ questionId: string,
+ ipAddress: string,
+ ): Promise<boolean> {
+ const ipHash = await this.hashIpAddress(ipAddress);
+ return await this.repository.hasUserLikedAnswer(questionId, ipHash);
+ }
+
+ private async hashIpAddress(ipAddress: string): Promise<string> {
+ const salt = Deno.env.get("IP_HASH_SALT") || "default-salt";
+ const data = new TextEncoder().encode(ipAddress + salt);
+ const hashBuffer = await crypto.subtle.digest("SHA-256", data);
+ const hashArray = Array.from(new Uint8Array(hashBuffer));
+ return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
+ }
+}
+
+class WebhookService {
+ constructor(private repository: KVRepository) {}
+
+ async notifyNewQuestion(question: Question): Promise<void> {
+ const webhookUrl = await this.repository.getWebhookUrl();
+
+ if (!webhookUrl) {
+ console.log("No webhook URL configured, skipping notification");
+ return;
+ }
+
+ const success = await this.sendWebhookWithRetry(webhookUrl, question);
+
+ if (!success) {
+ await this.saveFailedWebhook(question, "All retry attempts failed");
+ }
+ }
+
+ async retryFailedWebhook(
+ timestamp: number,
+ questionId: string,
+ ): Promise<boolean> {
+ const webhooks = await this.repository.getAllFailedWebhooks();
+ const webhook = webhooks.find(
+ (w) => w.createdAt === timestamp && w.questionId === questionId,
+ );
+
+ if (!webhook) return false;
+
+ const webhookUrl = await this.repository.getWebhookUrl();
+ if (!webhookUrl) return false;
+
+ const question = await this.repository.getQuestion(questionId);
+ if (!question) return false;
+
+ const success = await this.sendWebhookWithRetry(webhookUrl, question);
+
+ if (success) {
+ await this.repository.deleteFailedWebhook(timestamp, questionId);
+ }
+
+ return success;
+ }
+
+ private async sendWebhookWithRetry(
+ url: string,
+ question: Question,
+ ): Promise<boolean> {
+ const payload = this.buildDiscordPayload(question);
+
+ for (let attempt = 0; attempt < CONFIG.WEBHOOK_RETRY_ATTEMPTS; attempt++) {
+ if (attempt > 0) {
+ await this.sleep(CONFIG.WEBHOOK_RETRY_DELAYS[attempt]);
+ }
+
+ try {
+ const response = await fetch(url, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(payload),
+ });
+
+ if (response.ok) {
+ return true;
+ }
+
+ console.error(
+ `Webhook attempt ${
+ attempt + 1
+ } failed with status ${response.status}`,
+ );
+ } catch (error) {
+ console.error(`Webhook attempt ${attempt + 1} error:`, error);
+ }
+ }
+
+ return false;
+ }
+
+ private buildDiscordPayload(question: Question) {
+ const appUrl = Deno.env.get("APP_URL") || "http://localhost:8000";
+ const answerUrl = `${appUrl}/question/${question.id}`;
+
+ return {
+ embeds: [{
+ title: "๐ New Question",
+ description: `${question.question}\n\n[answer here](${answerUrl})`,
+ color: 3447003, // Blue
+ timestamp: new Date(question.createdAt).toISOString(),
+ }],
+ };
+ }
+
+ private async saveFailedWebhook(
+ question: Question,
+ error: string,
+ ): Promise<void> {
+ const failedWebhook: FailedWebhook = {
+ questionId: question.id,
+ question: question.question,
+ attempts: CONFIG.WEBHOOK_RETRY_ATTEMPTS,
+ lastError: error,
+ createdAt: Date.now(),
+ };
+
+ await this.repository.saveFailedWebhook(failedWebhook);
+ }
+
+ private sleep(ms: number): Promise<void> {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+ }
+}
+
+// ============================================================================
+// MIDDLEWARE
+// ============================================================================
+
+function authMiddleware(authService: AuthenticationService) {
+ return async (c: any, next: () => Promise<void>) => {
+ const sessionId = getCookie(c, CONFIG.SESSION_COOKIE_NAME);
+
+ if (!sessionId) {
+ // Capture the current path to redirect back after login
+ const redirectTo = encodeURIComponent(c.req.path);
+ return c.redirect(`/login?redirect=${redirectTo}`);
+ }
+
+ const username = await authService.validateSession(sessionId);
+
+ if (!username) {
+ deleteCookie(c, CONFIG.SESSION_COOKIE_NAME);
+ // Capture the current path to redirect back after login
+ const redirectTo = encodeURIComponent(c.req.path);
+ return c.redirect(`/login?redirect=${redirectTo}`);
+ }
+
+ await next();
+ };
+}
+
+function extractIpAddress(c: any): string {
+ return c.req.header("x-forwarded-for")?.split(",")[0].trim() ||
+ c.req.header("x-real-ip") ||
+ "unknown";
+}
+
+// ============================================================================
+// UI COMPONENTS (Atomic Design)
+// ============================================================================
+
+// Atoms
+const Button = ({ type = "button", children, ...props }: any) => (
+ <button type={type} {...props}>{children}</button>
+);
+
+const Input = (props: any) => <input {...props} />;
+
+const Textarea = (props: any) => <textarea {...props} />;
+
+// ============================================================================
+// PHASE 2: PUBLIC FEATURES - UI COMPONENTS GO HERE
+// ============================================================================
+const LikeButton = ({
+ type,
+ questionId,
+ count,
+ liked = false,
+}: {
+ type: "question" | "answer";
+ questionId: string;
+ count: number;
+ liked?: boolean;
+}) => {
+ const emoji = type === "question"
+ ? (liked ? "โค๏ธ" : "๐ค") // Red heart when liked, white heart when not
+ : (liked ? "๐" : "๐๐ป"); // Darker thumb when liked, lighter when not
+ const endpoint = `/api/like/${type}/${questionId}`;
+
+ return (
+ <form
+ method="post"
+ action={endpoint}
+ style="display: inline;"
+ class="like-form"
+ >
+ <button
+ type="submit"
+ class={`like-btn ${liked ? "liked" : ""}`}
+ >
+ {emoji} <span class="count">{count}</span>
+ </button>
+ </form>
+ );
+};
+
+const QuestionCard = (
+ { question, userIp }: { question: Question; userIp: string },
+) => {
+ const formatter = new Intl.DateTimeFormat("en-US", {
+ year: "numeric",
+ month: "short",
+ day: "numeric",
+ });
+
+ const questionLiked = question.questionLiked || false;
+ const answerLiked = question.answerLiked || false;
+
+ return (
+ <div class="question-card" id={question.id}>
+ <div class="question-content">
+ <p class="question-text">
+ <strong>Question:</strong> {question.question}
+ </p>
+ {question.answer && (
+ <div class="answer-content">
+ <p class="answer-text">
+ Answer: {question.answer}
+ </p>
+ <div class="answer-meta">
+ <time datetime={new Date(question.answeredAt!).toISOString()}>
+ {formatter.format(new Date(question.answeredAt!))}
+ </time>
+ <LikeButton
+ type="answer"
+ questionId={question.id}
+ count={question.answerLikes}
+ liked={answerLiked}
+ />
+ </div>
+ </div>
+ )}
+ <div class="question-meta">
+ <LikeButton
+ type="question"
+ questionId={question.id}
+ count={question.questionLikes}
+ liked={questionLiked}
+ />
+ </div>
+ </div>
+ </div>
+ );
+};
+
+const QuestionForm = () => (
+ <form method="post" action="/" class="question-form">
+ <Textarea
+ name="question"
+ placeholder="Ask your question here..."
+ maxlength={CONFIG.CHARACTER_LIMITS.QUESTION}
+ required
+ />
+ <div class="form-footer">
+ <span class="char-count">
+ Max {CONFIG.CHARACTER_LIMITS.QUESTION} characters
+ </span>
+ <Button type="submit">Ask Question</Button>
+ </div>
+ </form>
+);
+
+// ============================================================================
+// LAYOUT COMPONENTS
+// ============================================================================
+
+const Layout = ({ title, children }: { title: string; children: any }) => (
+ <html lang="en">
+ <head>
+ <meta charset="UTF-8" />
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+ <title>{title}</title>
+ <style>{getStyles()}</style>
+ </head>
+ <body>
+ {children}
+ </body>
+ </html>
+);
+
+const AboutPage = () => (
+ <Layout title="About - AMA">
+ <main>
+ <div style="max-width: 600px; margin: 0 auto;">
+ <section style="margin-bottom: 32px;">
+ <h2 style="font-size: 24px; margin-bottom: 16px;">What is this?</h2>
+ <p style="margin-bottom: 16px;">
+ This is an Ask Me Anything (AMA) platform where anyone can ask
+ questions and we'll answer them publicly. Questions are sorted by
+ community engagement, so the most popular questions rise to the top.
+ </p>
+ </section>
+
+ <section style="margin-bottom: 32px;">
+ <h2 style="font-size: 24px; margin-bottom: 16px;">
+ How to ask a question
+ </h2>
+ <ol style="margin: 0; padding-left: 24px;">
+ <li style="margin-bottom: 12px;">
+ Visit the homepage and use the question form at the top
+ </li>
+ <li style="margin-bottom: 12px;">
+ Type your question (maximum 500 characters)
+ </li>
+ <li style="margin-bottom: 12px;">
+ Click "Ask Question" to submit
+ </li>
+ <li style="margin-bottom: 12px;">
+ Your question will appear on the page immediately
+ </li>
+ </ol>
+ </section>
+
+ <section style="margin-bottom: 32px;">
+ <h2 style="font-size: 24px; margin-bottom: 16px;">How likes work</h2>
+ <p style="margin-bottom: 16px;">
+ You can show your interest in questions by liking them:
+ </p>
+ <ul style="margin: 0; padding-left: 24px;">
+ <li style="margin-bottom: 12px;">
+ <strong>Question likes (๐ค/โค๏ธ):</strong>{" "}
+ Click the heart to show you want this question answered. Click
+ again to unlike.
+ </li>
+ <li style="margin-bottom: 12px;">
+ <strong>Answer likes (๐๐ป/๐):</strong>{" "}
+ Once a question is answered, you can like the answer if you found
+ it helpful.
+ </li>
+ <li style="margin-bottom: 12px;">
+ You can like once per question/answer per day
+ </li>
+ </ul>
+ </section>
+
+ <section style="margin-bottom: 32px;">
+ <h2 style="font-size: 24px; margin-bottom: 16px;">
+ How sorting works
+ </h2>
+ <p style="margin-bottom: 16px;">
+ Questions are sorted by engagement score:
+ </p>
+ <ul style="margin: 0; padding-left: 24px;">
+ <li style="margin-bottom: 12px;">
+ Each question like adds to the engagement score
+ </li>
+ <li style="margin-bottom: 12px;">
+ Answer likes are weighted slightly higher (1.5x) to promote
+ quality answers
+ </li>
+ <li style="margin-bottom: 12px;">
+ Questions with higher engagement appear at the top of the page
+ </li>
+ <li style="margin-bottom: 12px;">
+ New questions with no engagement appear at the bottom
+ </li>
+ </ul>
+ </section>
+
+ <section style="margin-bottom: 32px;">
+ <h2 style="font-size: 24px; margin-bottom: 16px;">Privacy & Data</h2>
+ <p style="margin-bottom: 16px;">
+ Your privacy is important:
+ </p>
+ <ul style="margin: 0; padding-left: 24px;">
+ <li style="margin-bottom: 12px;">
+ Questions are public and visible to everyone
+ </li>
+ <li style="margin-bottom: 12px;">
+ We don't collect personal information
+ </li>
+ <li style="margin-bottom: 12px;">
+ Likes are tracked by ID and hashed for privacy to prevent spam
+ </li>
+ <li style="margin-bottom: 12px;">
+ No account or email required to participate
+ </li>
+ </ul>
+ </section>
+
+ <div style="text-align: center; margin-top: 48px;">
+ <a
+ href="/"
+ style="display: inline-block; background: #3B82F6; color: #fff; padding: 12px 24px; border-radius: 4px; text-decoration: none; font-weight: 600;"
+ >
+ Back to Questions
+ </a>
+ </div>
+ </div>
+ </main>
+ <Footer />
+ </Layout>
+);
+
+const Footer = () => (
+ <footer>
+ <p>
+ <a href="/about">About this site</a>
+ </p>
+ </footer>
+);
+
+// ============================================================================
+// STYLES
+// ============================================================================
+
+function getStyles(): string {
+ return `
+ * {
+ box-sizing: border-box;
+ font-family: Tahoma, sans-serif;
+ }
+
+ body {
+ line-height: 1.6;
+ padding: 0;
+ margin: 0;
+ font-size: 18px;
+ color: #000;
+ background: #fff;
+ }
+
+ main {
+ max-width: 680px;
+ margin: 40px auto;
+ padding: 0 20px;
+ }
+
+ footer {
+ padding: 20px;
+ text-align: center;
+ margin-top: 60px;
+ color: #666;
+ font-size: 14px;
+ }
+
+ a {
+ color: ${CONFIG.ACCENT_COLOR};
+ text-decoration: none;
+ }
+
+ a:hover {
+ text-decoration: underline;
+ }
+
+ /* Question Form */
+ .question-form {
+ position: relative;
+ margin-bottom: 40px;
+ padding: 20px;
+ border-radius: 8px;
+ background: #f9f9f9;
+ }
+
+ .question-form textarea {
+ width: 100%;
+ min-height: 120px;
+ padding: 12px;
+ font: inherit;
+ border: 1px solid #ccc;
+ border-radius: 4px;
+ resize: vertical;
+ margin-bottom: 12px;
+ }
+
+ .question-form textarea:focus {
+ outline: none;
+ border-color: ${CONFIG.ACCENT_COLOR};
+ box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
+ }
+
+ .question-form .form-footer {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ }
+
+ .question-form .char-count {
+ font-size: 14px;
+ color: #999;
+ }
+
+ .question-form button {
+ background: ${CONFIG.ACCENT_COLOR};
+ color: #fff;
+ border: none;
+ padding: 10px 24px;
+ font: inherit;
+ font-weight: 600;
+ border-radius: 4px;
+ cursor: pointer;
+ transition: transform 0.1s, opacity 0.2s;
+ }
+
+ .question-form button:hover {
+ opacity: 0.9;
+ transform: translateY(-1px);
+ }
+
+ .question-form button:active {
+ transform: translateY(0);
+ }
+
+ /* Question Card */
+ .question-card {
+ margin-bottom: 40px;
+ padding: 0;
+ border: none;
+ background: transparent;
+ }
+
+ .question-content {
+ margin-bottom: 6px;
+ }
+
+ .question-text {
+ margin: 0 0 1px 0;
+ font-size: 20px;
+ line-height: 1.5;
+ font-weight: 700;
+ }
+
+ .question-text strong {
+ font-weight: 700;
+ }
+
+ .question-meta {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ }
+
+ .answer-content {
+ padding-top: 5px;
+ margin-top: 1px;
+ }
+
+ .answer-text {
+ margin: 0 0 1px 0;
+ font-size: 18px;
+ color: #333;
+ line-height: 1.6;
+ white-space: pre-wrap;
+ font-weight: 400;
+ }
+
+ .answer-text strong {
+ font-weight: 600;
+ }
+
+ .answer-meta {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ }
+
+ .unanswered {
+ display: none;
+ }
+
+ time {
+ color: #999;
+ font-size: 14px;
+ }
+
+ /* Like Button */
+ .like-form {
+ margin: 0;
+ padding: 0;
+ }
+
+ .like-btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 2px;
+ padding: 4px;
+ background: transparent;
+ border: none;
+ cursor: pointer;
+ font: inherit;
+ font-size: 12px;
+ transition: transform 0.1s;
+ }
+
+ .like-btn:not([disabled]):hover {
+ transform: scale(1.1);
+ }
+
+ .like-btn:not([disabled]):active {
+ transform: scale(0.95);
+ }
+
+ .like-btn:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+ }
+
+ .like-btn .count {
+ font-weight: 600;
+ color: #666;
+ }
+
+ .like-btn.liked .count {
+ color: #666;
+ }
+
+ /* Login Form */
+ .login-form {
+ max-width: 400px;
+ margin: 60px auto;
+ padding: 32px;
+ border-radius: 8px;
+ background: #f9f9f9;
+ }
+
+ .login-form h2 {
+ margin-top: 0;
+ margin-bottom: 24px;
+ }
+
+ .login-form input {
+ width: 100%;
+ padding: 12px;
+ margin-bottom: 16px;
+ font: inherit;
+ border: 1px solid #ccc;
+ border-radius: 4px;
+ }
+
+ .login-form input:focus {
+ outline: none;
+ border-color: ${CONFIG.ACCENT_COLOR};
+ box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
+ }
+
+ .login-form button {
+ width: 100%;
+ background: ${CONFIG.ACCENT_COLOR};
+ color: #fff;
+ border: none;
+ padding: 12px;
+ font: inherit;
+ font-weight: 600;
+ border-radius: 4px;
+ cursor: pointer;
+ transition: opacity 0.2s;
+ }
+
+ .login-form button:hover {
+ opacity: 0.9;
+ }
+
+ /* Admin Dashboard */
+ .admin-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 24px;
+ padding-bottom: 16px;
+ border-bottom: 1px solid #000;
+ }
+
+ .admin-header h2 {
+ margin: 0;
+ }
+
+ .admin-header-links {
+ display: flex;
+ gap: 16px;
+ align-items: center;
+ }
+
+ .admin-actions {
+ display: flex;
+ gap: 12px;
+ margin-bottom: 24px;
+ align-items: center;
+ }
+
+ .admin-actions label {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ }
+
+ .admin-actions button {
+ padding: 8px 16px;
+ font: inherit;
+ border: 1px solid #000;
+ border-radius: 4px;
+ background: #fff;
+ cursor: pointer;
+ transition: background 0.2s;
+ }
+
+ .admin-actions button.primary {
+ background: #dc2626;
+ color: #fff;
+ border-color: #dc2626;
+ }
+
+ .admin-actions button:hover {
+ opacity: 0.9;
+ }
+
+ .admin-question-card {
+ margin-bottom: 16px;
+ padding: 16px;
+ border: 1px solid #ddd;
+ border-radius: 8px;
+ display: flex;
+ gap: 12px;
+ align-items: flex-start;
+ background: #fff;
+ }
+
+ .admin-question-card input[type="checkbox"] {
+ margin-top: 4px;
+ cursor: pointer;
+ }
+
+ .admin-question-content {
+ flex: 1;
+ }
+
+ .admin-question-content p {
+ margin: 0 0 8px 0;
+ }
+
+ .admin-question-meta {
+ display: flex;
+ gap: 16px;
+ align-items: center;
+ color: #666;
+ font-size: 14px;
+ }
+
+ .admin-question-actions {
+ display: flex;
+ gap: 8px;
+ }
+
+ .admin-question-actions a {
+ padding: 6px 12px;
+ font-size: 14px;
+ text-decoration: none;
+ border: 1px solid #000;
+ border-radius: 4px;
+ background: #fff;
+ transition: background 0.2s;
+ }
+
+ .admin-question-actions a:hover {
+ background: #f5f5f5;
+ }
+
+ /* Answer Form */
+ .answer-form {
+ max-width: 680px;
+ margin: 40px auto;
+ padding: 24px;
+ border-radius: 8px;
+ background: #f9f9f9;
+ }
+
+ .answer-form .question-display {
+ padding: 16px;
+ margin-bottom: 24px;
+ background: #fff;
+ border: 1px solid #ddd;
+ border-radius: 4px;
+ }
+
+ .answer-form .question-display p {
+ margin: 0 0 8px 0;
+ }
+
+ .answer-form .question-display p:last-child {
+ margin: 0;
+ }
+
+ .answer-form label {
+ display: block;
+ margin-bottom: 8px;
+ font-weight: 600;
+ }
+
+ .answer-form textarea {
+ width: 100%;
+ min-height: 200px;
+ padding: 12px;
+ font: inherit;
+ border: 1px solid #ccc;
+ border-radius: 4px;
+ resize: vertical;
+ margin-bottom: 16px;
+ }
+
+ .answer-form textarea:focus {
+ outline: none;
+ border-color: ${CONFIG.ACCENT_COLOR};
+ box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
+ }
+
+ .answer-form .form-actions {
+ display: flex;
+ gap: 12px;
+ align-items: center;
+ }
+
+ .answer-form button {
+ background: ${CONFIG.ACCENT_COLOR};
+ color: #fff;
+ border: none;
+ padding: 12px 24px;
+ font: inherit;
+ font-weight: 600;
+ border-radius: 4px;
+ cursor: pointer;
+ transition: opacity 0.2s;
+ }
+
+ .answer-form button:hover {
+ opacity: 0.9;
+ }
+
+ /* Settings Page */
+ .settings-section {
+ margin-bottom: 40px;
+ }
+
+ .settings-section h3 {
+ margin-bottom: 16px;
+ padding-bottom: 8px;
+ border-bottom: 1px solid #ddd;
+ }
+
+ .settings-form input {
+ width: 100%;
+ padding: 12px;
+ margin-bottom: 12px;
+ font: inherit;
+ border: 1px solid #ccc;
+ border-radius: 4px;
+ }
+
+ .settings-form input:focus {
+ outline: none;
+ border-color: ${CONFIG.ACCENT_COLOR};
+ box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
+ }
+
+ .settings-form button {
+ background: ${CONFIG.ACCENT_COLOR};
+ color: #fff;
+ border: none;
+ padding: 10px 20px;
+ font: inherit;
+ font-weight: 600;
+ border-radius: 4px;
+ cursor: pointer;
+ transition: opacity 0.2s;
+ }
+
+ .settings-form button:hover {
+ opacity: 0.9;
+ }
+
+ .failed-webhook-item {
+ padding: 16px;
+ margin-bottom: 12px;
+ border: 1px solid #ddd;
+ border-radius: 4px;
+ background: #fff;
+ }
+
+ .failed-webhook-item .webhook-question {
+ font-weight: 600;
+ margin-bottom: 8px;
+ }
+
+ .failed-webhook-item .webhook-meta {
+ font-size: 14px;
+ color: #666;
+ margin-bottom: 8px;
+ }
+
+ .failed-webhook-item .webhook-error {
+ font-size: 14px;
+ color: #dc2626;
+ margin-bottom: 12px;
+ font-family: monospace;
+ }
+
+ .failed-webhook-item button {
+ padding: 6px 12px;
+ font-size: 14px;
+ background: #fff;
+ border: 1px solid #000;
+ border-radius: 4px;
+ cursor: pointer;
+ transition: background 0.2s;
+ }
+
+ .failed-webhook-item button:hover {
+ background: #f5f5f5;
+ }
+
+ /* Utility Classes */
+ .error {
+ padding: 12px;
+ margin-bottom: 16px;
+ background: #fee;
+ border: 1px solid #fcc;
+ border-radius: 4px;
+ color: #c00;
+ }
+
+ .success {
+ padding: 12px;
+ margin-bottom: 16px;
+ background: #efe;
+ border: 1px solid #cfc;
+ border-radius: 4px;
+ color: #060;
+ }
+
+ .empty-state {
+ padding: 60px 20px;
+ text-align: center;
+ color: #999;
+ }
+
+ .empty-state p {
+ margin: 0;
+ font-size: 18px;
+ }
+ `;
+}
+
+// ============================================================================
+// ROUTE HANDLERS
+// ============================================================================
+
+class RouteHandlers {
+ constructor(
+ private questionService: QuestionService,
+ private likeService: LikeService,
+ private authService: AuthenticationService,
+ private webhookService: WebhookService,
+ private repository: KVRepository,
+ ) {}
+
+ // ============================================================================
+ // PHASE 2: PUBLIC ROUTE HANDLERS GO HERE
+ // ============================================================================
+ async renderHomepage(c: any) {
+ // Check if user is authenticated
+ const sessionId = getCookie(c, CONFIG.SESSION_COOKIE_NAME);
+ let isAuthenticated = false;
+ let username = null;
+
+ if (sessionId) {
+ username = await this.authService.validateSession(sessionId);
+ isAuthenticated = !!username;
+ }
+
+ // If authenticated, render admin dashboard
+ if (isAuthenticated) {
+ return this.renderAdminDashboard(c);
+ }
+
+ // Otherwise, render public view
+ const questions = await this.questionService.getAllQuestions();
+ const error = c.req.query("error");
+ const success = c.req.query("success");
+ const ipAddress = extractIpAddress(c);
+
+ // Check like status for each question
+ const questionsWithLikeStatus = await Promise.all(
+ questions.map(async (question) => {
+ const questionLiked = await this.likeService.checkQuestionLikeStatus(
+ question.id,
+ ipAddress,
+ );
+ const answerLiked = question.answer
+ ? await this.likeService.checkAnswerLikeStatus(question.id, ipAddress)
+ : false;
+
+ return {
+ ...question,
+ questionLiked,
+ answerLiked,
+ };
+ }),
+ );
+
+ questionsWithLikeStatus.reverse();
+
+ return c.html(
+ <Layout title="AMA">
+ <main>
+ {error === "empty" && (
+ <div class="error">
+ Please enter a question before submitting.
+ </div>
+ )}
+ {error === "failed" && (
+ <div class="error">
+ Failed to submit question. Please try again.
+ </div>
+ )}
+ {error === "toolong" && (
+ <div class="error">
+ Question is too long. Maximum {CONFIG.CHARACTER_LIMITS.QUESTION}
+ {" "}
+ characters.
+ </div>
+ )}
+ {success === "submitted" && (
+ <div class="success">
+ Question submitted successfully!
+ </div>
+ )}
+
+ <QuestionForm />
+
+ {questionsWithLikeStatus.length === 0
+ ? (
+ <div class="empty-state">
+ <p>No questions yet. Be the first to ask!</p>
+ </div>
+ )
+ : (
+ questionsWithLikeStatus.map((question) => (
+ <QuestionCard question={question} userIp={ipAddress} />
+ ))
+ )}
+ </main>
+ <Footer />
+ </Layout>,
+ );
+ }
+
+ async submitQuestion(c: any) {
+ try {
+ const formData = await c.req.formData();
+ const questionText = formData.get("question") as string;
+
+ if (!questionText?.trim()) {
+ return c.redirect("/?error=empty");
+ }
+
+ const question = await this.questionService.createQuestion(questionText);
+
+ // Fire webhook asynchronously (don't block response)
+ this.webhookService.notifyNewQuestion(question).catch((err) => {
+ console.error("Webhook notification failed:", err);
+ });
+
+ return c.redirect("/?success=submitted");
+ } catch (error) {
+ console.error("Failed to submit question:", error);
+
+ if (
+ error instanceof Error &&
+ error.message.includes("exceeds maximum length")
+ ) {
+ return c.redirect("/?error=toolong");
+ }
+
+ return c.redirect("/?error=failed");
+ }
+ }
+
+ async likeQuestion(c: any) {
+ try {
+ const questionId = c.req.param("id");
+ const ipAddress = extractIpAddress(c);
+
+ const result = await this.likeService.toggleQuestionLike(
+ questionId,
+ ipAddress,
+ );
+
+ if (!result) {
+ return c.redirect("/");
+ }
+
+ // Redirect back to homepage with anchor
+ return c.redirect("/#" + questionId);
+ } catch (error) {
+ console.error("Failed to toggle question like:", error);
+ return c.redirect("/");
+ }
+ }
+
+ async likeAnswer(c: any) {
+ try {
+ const questionId = c.req.param("id");
+ const ipAddress = extractIpAddress(c);
+
+ const result = await this.likeService.toggleAnswerLike(
+ questionId,
+ ipAddress,
+ );
+
+ if (!result) {
+ return c.redirect("/");
+ }
+
+ // Redirect back to homepage with anchor
+ return c.redirect("/#" + questionId);
+ } catch (error) {
+ console.error("Failed to toggle answer like:", error);
+ return c.redirect("/");
+ }
+ }
+
+ renderAboutPage(c: any) {
+ return c.html(<AboutPage />);
+ }
+
+ // ============================================================================
+ // PHASE 3: ADMIN ROUTE HANDLERS
+ // ============================================================================
+
+ renderLoginPage(c: any) {
+ const error = c.req.query("error");
+ const redirect = c.req.query("redirect");
+
+ // Build the form action with redirect parameter if present
+ const formAction = redirect ? `/login?redirect=${redirect}` : "/login";
+
+ return c.html(
+ <Layout title="Login - AMA">
+ <main>
+ <form method="post" action={formAction} class="login-form">
+ <h2>Admin Login</h2>
+
+ {error === "invalid" && (
+ <div class="error">
+ Invalid username or password
+ </div>
+ )}
+ {error === "failed" && (
+ <div class="error">
+ Login failed. Please try again.
+ </div>
+ )}
+
+ <Input
+ type="text"
+ name="username"
+ placeholder="Username"
+ required
+ autofocus
+ />
+ <Input
+ type="password"
+ name="password"
+ placeholder="Password"
+ required
+ />
+ <Button type="submit">Login</Button>
+ </form>
+ </main>
+ </Layout>,
+ );
+ }
+
+ async handleLogin(c: any) {
+ try {
+ const formData = await c.req.formData();
+ const username = formData.get("username") as string;
+ const password = formData.get("password") as string;
+
+ const isAuthenticated = await this.authService.authenticateUser(
+ username,
+ password,
+ );
+
+ if (!isAuthenticated) {
+ const redirect = c.req.query("redirect");
+ const redirectParam = redirect ? `?redirect=${redirect}` : "";
+ return c.redirect(`/login${redirectParam}?error=invalid`);
+ }
+
+ const sessionId = await this.authService.createUserSession(username);
+
+ setCookie(c, CONFIG.SESSION_COOKIE_NAME, sessionId, {
+ httpOnly: true,
+ secure: Deno.env.get("DENO_ENV") === "production",
+ sameSite: "Lax",
+ maxAge: CONFIG.SESSION_EXPIRY_DAYS * 24 * 60 * 60,
+ });
+
+ // Redirect to the original destination or home
+ const redirectTo = c.req.query("redirect") || "/";
+ return c.redirect(decodeURIComponent(redirectTo));
+ } catch (error) {
+ console.error("Login error:", error);
+ const redirect = c.req.query("redirect");
+ const redirectParam = redirect ? `?redirect=${redirect}` : "";
+ return c.redirect(`/login${redirectParam}?error=failed`);
+ }
+ }
+
+ async handleLogout(c: any) {
+ const sessionId = getCookie(c, CONFIG.SESSION_COOKIE_NAME);
+
+ if (sessionId) {
+ await this.authService.destroySession(sessionId);
+ }
+
+ deleteCookie(c, CONFIG.SESSION_COOKIE_NAME);
+
+ return c.redirect("/");
+ }
+
+ async renderAdminDashboard(c: any) {
+ const username = await this.authService.validateSession(
+ getCookie(c, CONFIG.SESSION_COOKIE_NAME)!,
+ );
+ const questions = await this.questionService.getAllQuestions();
+ const success = c.req.query("success");
+ const error = c.req.query("error");
+
+ return c.html(
+ <Layout title="Admin Dashboard - AMA">
+ <main>
+ <div class="admin-header">
+ <h2>All Questions ({questions.length})</h2>
+ <div class="admin-header-links">
+ <p>{username}</p>
+ <span>|</span>
+ <a href="/settings">Settings</a>
+ <span>|</span>
+ <a href="/logout">Logout</a>
+ </div>
+ </div>
+
+ {success === "deleted" && (
+ <div class="success">
+ Selected questions deleted successfully!
+ </div>
+ )}
+ {success === "answered" && (
+ <div class="success">
+ Answer saved successfully!
+ </div>
+ )}
+ {error === "noselection" && (
+ <div class="error">
+ Please select at least one question to delete.
+ </div>
+ )}
+ {error === "notfound" && (
+ <div class="error">
+ Question not found.
+ </div>
+ )}
+
+ <form method="post" action="/bulk-delete" id="bulkDeleteForm">
+ <div class="admin-actions">
+ <label>
+ <input type="checkbox" id="selectAll" /> Select All
+ </label>
+ <button type="submit" class="primary">Delete Selected</button>
+ </div>
+
+ {questions.length === 0
+ ? (
+ <div class="empty-state">
+ <p>No questions yet.</p>
+ </div>
+ )
+ : (
+ questions.map((question) => (
+ <div class="admin-question-card">
+ <input
+ type="checkbox"
+ name="questionIds"
+ value={question.id}
+ class="question-checkbox"
+ />
+ <div class="admin-question-content">
+ <p>
+ <strong>{question.question}</strong>
+ </p>
+ <div class="admin-question-meta">
+ <span>โค๏ธ {question.questionLikes} likes</span>
+ {question.answer && (
+ <span>๐ {question.answerLikes} thumbs</span>
+ )}
+ <span>
+ {question.answer ? "โ
Answered" : "โณ Unanswered"}
+ </span>
+ </div>
+ </div>
+ <div class="admin-question-actions">
+ <a href={`/question/${question.id}`}>
+ {question.answer ? "Edit Answer" : "Answer"}
+ </a>
+ </div>
+ </div>
+ ))
+ )}
+ </form>
+
+ <script
+ dangerouslySetInnerHTML={{
+ __html: `
+ document.getElementById('selectAll').addEventListener('change', function(e) {
+ document.querySelectorAll('.question-checkbox').forEach(cb => {
+ cb.checked = e.target.checked;
+ });
+ });
+
+ document.getElementById('bulkDeleteForm').addEventListener('submit', function(e) {
+ const checked = document.querySelectorAll('.question-checkbox:checked');
+ if (checked.length === 0) {
+ e.preventDefault();
+ alert('Please select at least one question to delete');
+ return;
+ }
+ if (!confirm('Delete ' + checked.length + ' question(s)? This cannot be undone.')) {
+ e.preventDefault();
+ }
+ });
+ `,
+ }}
+ />
+ </main>
+ </Layout>,
+ );
+ }
+
+ async handleBulkDelete(c: any) {
+ try {
+ const formData = await c.req.formData();
+ const questionIds = formData.getAll("questionIds") as string[];
+
+ if (questionIds.length === 0) {
+ return c.redirect("/?error=noselection");
+ }
+
+ await this.questionService.deleteQuestions(questionIds);
+
+ return c.redirect("/?success=deleted");
+ } catch (error) {
+ console.error("Bulk delete error:", error);
+ return c.redirect("/?error=failed");
+ }
+ }
+
+ async renderAnswerForm(c: any) {
+ const questionId = c.req.param("id");
+ const question = await this.questionService.getQuestionById(questionId);
+ const error = c.req.query("error");
+
+ if (!question) {
+ return c.redirect("/?error=notfound");
+ }
+
+ return c.html(
+ <Layout title="Answer Question - AMA">
+ <main>
+ {error === "empty" && (
+ <div class="error">
+ Please enter an answer before submitting.
+ </div>
+ )}
+ {error === "toolong" && (
+ <div class="error">
+ Answer is too long. Maximum {CONFIG.CHARACTER_LIMITS.ANSWER}{" "}
+ characters.
+ </div>
+ )}
+ {error === "failed" && (
+ <div class="error">
+ Failed to save answer. Please try again.
+ </div>
+ )}
+
+ <form method="post" class="answer-form">
+ <div class="question-display">
+ <p>
+ <strong>Question:</strong>
+ </p>
+ <p>{question.question}</p>
+ <p style="margin-top: 12px; color: #666; font-size: 14px;">
+ โค๏ธ {question.questionLikes} likes
+ </p>
+ </div>
+
+ <label for="answer">
+ <strong>Your Answer:</strong>
+ </label>
+ <Textarea
+ id="answer"
+ name="answer"
+ placeholder="Type your answer here..."
+ maxlength={CONFIG.CHARACTER_LIMITS.ANSWER}
+ required
+ >
+ {question.answer || ""}
+ </Textarea>
+
+ <div class="form-actions">
+ <Button type="submit">
+ {question.answer ? "Update Answer" : "Submit Answer"}
+ </Button>
+ <a href="/">Cancel</a>
+ </div>
+ </form>
+ </main>
+ </Layout>,
+ );
+ }
+
+ async handleSubmitAnswer(c: any) {
+ try {
+ const questionId = c.req.param("id");
+ const formData = await c.req.formData();
+ const answerText = formData.get("answer") as string;
+
+ if (!answerText?.trim()) {
+ return c.redirect(`/question/${questionId}?error=empty`);
+ }
+
+ const question = await this.questionService.answerQuestion(
+ questionId,
+ answerText,
+ );
+
+ if (!question) {
+ return c.redirect("/?error=notfound");
+ }
+
+ return c.redirect("/?success=answered");
+ } catch (error) {
+ console.error("Submit answer error:", error);
+
+ if (
+ error instanceof Error &&
+ error.message.includes("exceeds maximum length")
+ ) {
+ return c.redirect(`/question/${c.req.param("id")}?error=toolong`);
+ }
+
+ return c.redirect(`/question/${c.req.param("id")}?error=failed`);
+ }
+ }
+
+ async renderSettingsPage(c: any) {
+ const webhookUrl = await this.repository.getWebhookUrl() || "";
+ const failedWebhooks = await this.repository.getAllFailedWebhooks();
+ const success = c.req.query("success");
+ const error = c.req.query("error");
+
+ return c.html(
+ <Layout title="Settings - AMA">
+ <main>
+ <div style="margin-bottom: 24px;">
+ <a href="/">โ Back to Dashboard</a>
+ </div>
+
+ {success === "true" && (
+ <div class="success">
+ Settings saved successfully!
+ </div>
+ )}
+ {success === "retry" && (
+ <div class="success">
+ Webhook retry successful!
+ </div>
+ )}
+ {error === "retry" && (
+ <div class="error">
+ Webhook retry failed. Please try again.
+ </div>
+ )}
+ {error === "failed" && (
+ <div class="error">
+ Failed to save settings. Please try again.
+ </div>
+ )}
+
+ <div class="settings-section">
+ <h3>Webhook Configuration</h3>
+ <p style="color: #666; margin-bottom: 16px;">
+ Enter your Discord webhook URL to receive notifications when new
+ questions are submitted.
+ </p>
+ <form
+ method="post"
+ action="/settings/webhook"
+ class="settings-form"
+ >
+ <label for="webhookUrl">Discord Webhook URL:</label>
+ <Input
+ type="url"
+ id="webhookUrl"
+ name="webhookUrl"
+ placeholder="https://discord.com/api/webhooks/..."
+ value={webhookUrl}
+ maxlength={CONFIG.CHARACTER_LIMITS.WEBHOOK_URL}
+ />
+ <Button type="submit">Save Webhook URL</Button>
+ </form>
+ </div>
+
+ <div class="settings-section">
+ <h3>Failed Webhooks ({failedWebhooks.length})</h3>
+
+ {failedWebhooks.length === 0
+ ? <p style="color: #666;">No failed webhooks</p>
+ : (
+ failedWebhooks.map((webhook) => {
+ const date = new Intl.DateTimeFormat("en-US", {
+ dateStyle: "medium",
+ timeStyle: "short",
+ }).format(new Date(webhook.createdAt));
+
+ return (
+ <div class="failed-webhook-item">
+ <div class="webhook-question">{webhook.question}</div>
+ <div class="webhook-meta">
+ Failed after {webhook.attempts} attempts | {date}
+ </div>
+ <div class="webhook-error">
+ Error: {webhook.lastError}
+ </div>
+ <form
+ method="post"
+ action={`/settings/retry/${webhook.createdAt}/${webhook.questionId}`}
+ style="display: inline;"
+ >
+ <button type="submit">Retry Webhook</button>
+ </form>
+ </div>
+ );
+ })
+ )}
+ </div>
+ </main>
+ </Layout>,
+ );
+ }
+
+ async handleSaveWebhookUrl(c: any) {
+ try {
+ const formData = await c.req.formData();
+ const webhookUrl = formData.get("webhookUrl") as string;
+
+ if (webhookUrl && webhookUrl.trim()) {
+ await this.repository.setWebhookUrl(webhookUrl.trim());
+ }
+
+ return c.redirect("/settings?success=true");
+ } catch (error) {
+ console.error("Save webhook error:", error);
+ return c.redirect("/settings?error=failed");
+ }
+ }
+
+ async handleRetryWebhook(c: any) {
+ try {
+ const timestamp = parseInt(c.req.param("timestamp"));
+ const questionId = c.req.param("questionId");
+
+ const success = await this.webhookService.retryFailedWebhook(
+ timestamp,
+ questionId,
+ );
+
+ if (success) {
+ return c.redirect("/settings?success=retry");
+ } else {
+ return c.redirect("/settings?error=retry");
+ }
+ } catch (error) {
+ console.error("Retry webhook error:", error);
+ return c.redirect("/settings?error=failed");
+ }
+ }
+
+ // ============================================================================
+}
+
+// ============================================================================
+// APPLICATION INITIALIZATION
+// ============================================================================
+
+async function initializeApp() {
+ // Initialize Deno KV
+ await Deno.mkdir(CONFIG.DATABASE_DIRECTORY, { recursive: true });
+ const kv = await Deno.openKv(`${CONFIG.DATABASE_DIRECTORY}/db`);
+
+ // Initialize repositories and services
+ const repository = new KVRepository(kv);
+ const authService = new AuthenticationService(repository);
+ const questionService = new QuestionService(repository);
+ const likeService = new LikeService(repository);
+ const webhookService = new WebhookService(repository);
+
+ const routeHandlers = new RouteHandlers(
+ questionService,
+ likeService,
+ authService,
+ webhookService,
+ repository,
+ );
+
+ // Create Hono app
+ const app = new Hono();
+
+ const requireAuth = authMiddleware(authService);
+
+ // ============================================================================
+ // PHASE 2: PUBLIC ROUTES
+ // ============================================================================
+ app.get("/", (c) => routeHandlers.renderHomepage(c));
+ app.post("/", (c) => routeHandlers.submitQuestion(c));
+ app.post("/api/like/question/:id", (c) => routeHandlers.likeQuestion(c));
+ app.post("/api/like/answer/:id", (c) => routeHandlers.likeAnswer(c));
+ app.get("/about", (c) => routeHandlers.renderAboutPage(c));
+
+ // ============================================================================
+ // PHASE 3: ADMIN ROUTES
+ // ============================================================================
+ app.get("/login", (c) => routeHandlers.renderLoginPage(c));
+ app.post("/login", (c) => routeHandlers.handleLogin(c));
+ app.get("/logout", (c) => routeHandlers.handleLogout(c));
+ app.get(
+ "/",
+ requireAuth,
+ (c) => routeHandlers.renderAdminDashboard(c),
+ );
+ app.post(
+ "/bulk-delete",
+ requireAuth,
+ (c) => routeHandlers.handleBulkDelete(c),
+ );
+ app.get(
+ "/question/:id",
+ requireAuth,
+ (c) => routeHandlers.renderAnswerForm(c),
+ );
+ app.post(
+ "/question/:id",
+ requireAuth,
+ (c) => routeHandlers.handleSubmitAnswer(c),
+ );
+ app.get(
+ "/settings",
+ requireAuth,
+ (c) => routeHandlers.renderSettingsPage(c),
+ );
+ app.post(
+ "/settings/webhook",
+ requireAuth,
+ (c) => routeHandlers.handleSaveWebhookUrl(c),
+ );
+ app.post(
+ "/settings/retry/:timestamp/:questionId",
+ requireAuth,
+ (c) => routeHandlers.handleRetryWebhook(c),
+ );
+ // ============================================================================
+
+ // ============================================================================
+ // PHASE 4: CRON JOBS GO HERE
+ // ============================================================================
+ Deno.cron("cleanup_sessions", CRON_SCHEDULES.CLEANUP_SESSIONS, async () => {
+ console.log("๐งน Running session cleanup...");
+ try {
+ const deleted = await repository.deleteExpiredSessions();
+ console.log(`โ
Cleaned up ${deleted} expired sessions`);
+ } catch (error) {
+ console.error("โ Session cleanup failed:", error);
+ }
+ });
+
+ Deno.cron(
+ "cleanup_failed_webhooks",
+ CRON_SCHEDULES.CLEANUP_FAILED_WEBHOOKS,
+ async () => {
+ console.log("๐งน Running failed webhook cleanup...");
+ try {
+ const deleted = await repository.deleteOldFailedWebhooks(30);
+ console.log(`โ
Cleaned up ${deleted} old failed webhooks (>30 days)`);
+ } catch (error) {
+ console.error("โ Failed webhook cleanup failed:", error);
+ }
+ },
+ );
+ // ============================================================================
+
+ return app;
+}
+
+// ============================================================================
+// SERVER START
+// ============================================================================
+
+if (import.meta.main) {
+ const app = await initializeApp();
+ const port = parseInt(Deno.env.get("PORT") || "5959");
+
+ console.log(`๐ AMA server starting on http://localhost:${port}`);
+ console.log(`โ
Phase 1 complete: Core structure initialized`);
+ console.log(`โ
Phase 2 complete: Public features active`);
+ console.log(`โ
Phase 3 complete: Admin features active`);
+ console.log(`โ
Phase 4 complete: Background tasks scheduled`);
+ console.log(`โ
Phase 5 complete: Enhancements applied`);
+ console.log(`๐ Main route:`);
+ console.log(
+ ` GET / - Homepage (public) or Dashboard (admin)`,
+ );
+ console.log(` Public routes:`);
+ console.log(` POST / - Submit question`);
+ console.log(` POST /api/like/... - Toggle like on questions/answers`);
+ console.log(` Admin routes (require auth):`);
+ console.log(` GET /login - Admin login`);
+ console.log(` GET /question/:id - Answer/edit question`);
+ console.log(` GET /settings - Configure webhook`);
+ console.log(` GET /logout - Logout`);
+ console.log(`โฐ Cron jobs:`);
+ console.log(` Daily at 3 AM - Clean expired sessions`);
+ console.log(` Weekly Sunday at 4 AM - Clean old failed webhooks`);
+ console.log(`๐ Application ready with unified routing!`);
+
+ Deno.serve({ port }, app.fetch);
+}