]> git.morado.dev Git - ama.twowitnessproject.org/commitdiff
Initial commit
authorRoberto Morado <roramigator@duck.com>
Mon, 10 Nov 2025 22:48:41 +0000 (17:48 -0500)
committerRoberto Morado <roramigator@duck.com>
Mon, 10 Nov 2025 22:48:41 +0000 (17:48 -0500)
.gitignore [new file with mode: 0644]
main.tsx [new file with mode: 0644]
users.ts [new file with mode: 0644]

diff --git a/.gitignore b/.gitignore
new file mode 100644 (file)
index 0000000..1bed868
--- /dev/null
@@ -0,0 +1 @@
+/storage
\ No newline at end of file
diff --git a/main.tsx b/main.tsx
new file mode 100644 (file)
index 0000000..96a559b
--- /dev/null
+++ b/main.tsx
@@ -0,0 +1,2372 @@
+// 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);
+}
diff --git a/users.ts b/users.ts
new file mode 100644 (file)
index 0000000..1fc3f10
--- /dev/null
+++ b/users.ts
@@ -0,0 +1,197 @@
+// users.ts - Manage blog users from the terminal
+// Examples:
+//   deno run --allow-read --allow-write --unstable-kv users.ts create
+//   deno run --allow-read --allow-write --unstable-kv users.ts list
+//   deno run --allow-read --allow-write --unstable-kv users.ts delete alice
+
+import * as bcrypt from "https://deno.land/x/bcrypt@v0.4.1/mod.ts";
+
+interface User {
+  username: string;
+  passwordHash: string;
+  createdAt: number;
+}
+
+const KV_PATH = "./storage/db";
+
+async function prompt(message: string): Promise<string> {
+  console.log(message);
+  const buf = new Uint8Array(1024);
+  const n = await Deno.stdin.read(buf);
+  return new TextDecoder().decode(buf.subarray(0, n || 0)).trim();
+}
+
+async function promptPassword(message: string): Promise<string> {
+  // Note: This is a simple implementation. In production, you'd want to hide input
+  console.log(message);
+  const buf = new Uint8Array(1024);
+  const n = await Deno.stdin.read(buf);
+  return new TextDecoder().decode(buf.subarray(0, n || 0)).trim();
+}
+
+async function promptYesNo(message: string): Promise<boolean> {
+  const answer = (await prompt(`${message} (y/N)`)).toLowerCase();
+  return answer === "y" || answer === "yes";
+}
+
+function printHelp() {
+  console.log("Manage blog users stored in Deno KV.\n");
+  console.log("Usage:");
+  console.log(
+    "  deno run --allow-read --allow-write --unstable-kv users.ts <command>\n",
+  );
+  console.log("Commands:");
+  console.log("  create            Create a new user (default)");
+  console.log("  list              List existing users with creation dates");
+  console.log("  delete <user>     Delete a user after confirmation");
+  console.log("  help              Show this message");
+}
+
+async function createUser() {
+  console.log("=".repeat(50));
+  console.log("Create New User for Minimalist Blog");
+  console.log("=".repeat(50));
+  console.log();
+
+  const username = await prompt("Enter username:");
+
+  if (!username || username.length < 3) {
+    console.error("โŒ Username must be at least 3 characters long");
+    Deno.exit(1);
+  }
+
+  const kv = await Deno.openKv(KV_PATH);
+
+  // Check if user already exists
+  const existingUser = await kv.get(["users", username]);
+  if (existingUser.value) {
+    console.error(`โŒ User "${username}" already exists`);
+    kv.close();
+    Deno.exit(1);
+  }
+
+  const password = await promptPassword("Enter password:");
+
+  if (!password || password.length < 6) {
+    console.error("โŒ Password must be at least 6 characters long");
+    kv.close();
+    Deno.exit(1);
+  }
+
+  const confirmPassword = await promptPassword("Confirm password:");
+
+  if (password !== confirmPassword) {
+    console.error("โŒ Passwords do not match");
+    kv.close();
+    Deno.exit(1);
+  }
+
+  console.log();
+  console.log("๐Ÿ” Hashing password...");
+
+  const passwordHash = await bcrypt.hash(password);
+
+  const user: User = {
+    username,
+    passwordHash,
+    createdAt: Date.now(),
+  };
+
+  await kv.set(["users", username], user);
+
+  console.log();
+  console.log("โœ… User created successfully!");
+  console.log(`   Username: ${username}`);
+  console.log(`   Created: ${new Date(user.createdAt).toLocaleString()}`);
+  console.log();
+  console.log("You can now login at http://localhost:8000/login");
+
+  kv.close();
+}
+
+async function listUsers() {
+  const kv = await Deno.openKv(KV_PATH);
+  const users: User[] = [];
+
+  const entries = kv.list<User>({ prefix: ["users"] });
+  for await (const entry of entries) {
+    users.push(entry.value);
+  }
+
+  kv.close();
+
+  if (users.length === 0) {
+    console.log("No users found in the database.");
+    return;
+  }
+
+  users.sort((a, b) => a.username.localeCompare(b.username));
+  console.log(`Found ${users.length} user(s):\n`);
+  console.log(`${"Username".padEnd(20)}Created`);
+  console.log("-".repeat(38));
+  for (const user of users) {
+    const created = new Date(user.createdAt).toLocaleString();
+    console.log(`${user.username.padEnd(20)}${created}`);
+  }
+}
+
+async function deleteUser(username: string | undefined) {
+  if (!username) {
+    console.error("โŒ Please provide a username to delete.");
+    Deno.exit(1);
+  }
+
+  const kv = await Deno.openKv(KV_PATH);
+  const key = ["users", username];
+  const result = await kv.get<User>(key);
+
+  if (!result.value) {
+    console.error(`โŒ User \"${username}\" does not exist.`);
+    kv.close();
+    Deno.exit(1);
+  }
+
+  const confirm = await promptYesNo(
+    `Are you sure you want to delete user \"${username}\"?`,
+  );
+  if (!confirm) {
+    console.log("Aborted.");
+    kv.close();
+    return;
+  }
+
+  await kv.delete(key);
+  kv.close();
+  console.log(`๐Ÿ—‘๏ธ  Deleted user \"${username}\".`);
+}
+
+async function main() {
+  const [command, maybeArg] = Deno.args;
+
+  switch (command) {
+    case "help":
+    case "--help":
+    case "-h":
+      printHelp();
+      return;
+    case "list":
+    case "--list":
+    case "-l":
+      await listUsers();
+      return;
+    case "delete":
+    case "--delete":
+      await deleteUser(maybeArg);
+      return;
+    case "create":
+    case undefined:
+      await createUser();
+      return;
+    default:
+      console.error(`Unknown command: ${command}`);
+      printHelp();
+      Deno.exit(1);
+  }
+}
+
+await main();