initial commit

This commit is contained in:
HPL-JesusCastro
2026-05-23 00:46:01 +08:00
commit ae2d6fa158
31 changed files with 8699 additions and 0 deletions

3273
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

26
backend/package.json Normal file
View File

@@ -0,0 +1,26 @@
{
"name": "hibb-backend",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "tsx watch --env-file=../.env src/index.ts",
"build": "tsc",
"start": "node dist/index.js"
},
"dependencies": {
"@fastify/cors": "^9.0.1",
"@fastify/jwt": "^8.0.1",
"@fastify/static": "^7.0.4",
"@libsql/client": "^0.14.0",
"bcryptjs": "^2.4.3",
"drizzle-orm": "^0.31.2",
"fastify": "^4.28.1"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/node": "^20.14.10",
"drizzle-kit": "^0.22.8",
"tsx": "^4.16.2",
"typescript": "^5.5.3"
}
}

40
backend/src/db/index.ts Normal file
View File

@@ -0,0 +1,40 @@
import { createClient } from "@libsql/client";
import { drizzle } from "drizzle-orm/libsql";
import fs from "fs";
import path from "path";
import { letters } from "./schema";
const DB_PATH = process.env.DB_PATH ?? "./data/letters.db";
// Ensure the parent directory exists before libsql tries to open the file
fs.mkdirSync(path.dirname(path.resolve(DB_PATH)), { recursive: true });
// libsql expects a file: URI; handle both relative and absolute paths
const dbUrl = DB_PATH.startsWith("/")
? `file://${DB_PATH}`
: `file:${DB_PATH}`;
const client = createClient({ url: dbUrl });
export const db = drizzle(client, { schema: { letters } });
export async function runMigrations() {
await client.execute(`
CREATE TABLE IF NOT EXISTS letters (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT,
content TEXT NOT NULL,
letter_date TEXT,
read_at TEXT,
deliver_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
// Additive migrations for existing databases
for (const col of [
"ALTER TABLE letters ADD COLUMN letter_date TEXT",
"ALTER TABLE letters ADD COLUMN read_at TEXT",
]) {
try { await client.execute(col); } catch { /* already exists */ }
}
}

17
backend/src/db/schema.ts Normal file
View File

@@ -0,0 +1,17 @@
import { sql } from "drizzle-orm";
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
export const letters = sqliteTable("letters", {
id: integer("id").primaryKey({ autoIncrement: true }),
title: text("title"),
content: text("content").notNull(),
letterDate: text("letter_date"),
readAt: text("read_at"),
deliverAt: text("deliver_at"),
createdAt: text("created_at")
.notNull()
.default(sql`(datetime('now'))`),
});
export type Letter = typeof letters.$inferSelect;
export type NewLetter = typeof letters.$inferInsert;

39
backend/src/index.ts Normal file
View File

@@ -0,0 +1,39 @@
import Fastify from "fastify";
import fastifyJwt from "@fastify/jwt";
import fastifyCors from "@fastify/cors";
import fastifyStatic from "@fastify/static";
import path from "path";
import fs from "fs";
import { runMigrations } from "./db";
import { authRoutes } from "./routes/auth";
import { letterRoutes } from "./routes/letters";
const app = Fastify({ logger: true });
async function main() {
await runMigrations();
await app.register(fastifyCors, { origin: true });
await app.register(fastifyJwt, {
secret: process.env.JWT_SECRET ?? "change-me-in-production",
});
await app.register(authRoutes);
await app.register(letterRoutes);
// Serve the React SPA in production
const distPath = path.join(__dirname, "../../frontend/dist");
if (fs.existsSync(distPath)) {
await app.register(fastifyStatic, { root: distPath, prefix: "/" });
app.setNotFoundHandler((_req, reply) => {
reply.sendFile("index.html");
});
}
await app.listen({ port: 3000, host: "0.0.0.0" });
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

View File

@@ -0,0 +1,9 @@
import { FastifyRequest, FastifyReply } from "fastify";
export async function verifyJwt(request: FastifyRequest, reply: FastifyReply) {
try {
await request.jwtVerify();
} catch {
reply.code(401).send({ error: "Unauthorized" });
}
}

View File

@@ -0,0 +1,38 @@
import { FastifyInstance } from "fastify";
import bcrypt from "bcryptjs";
type Role = "writer" | "reader";
async function checkPassword(input: string, stored: string): Promise<boolean> {
if (!stored) return false;
return stored.startsWith("$2") ? bcrypt.compare(input, stored) : input === stored;
}
export async function authRoutes(app: FastifyInstance) {
app.post<{ Body: { password: string } }>("/api/auth/login", {
schema: {
body: {
type: "object",
required: ["password"],
properties: { password: { type: "string" } },
},
},
handler: async (request, reply) => {
const { password } = request.body;
let role: Role | null = null;
if (await checkPassword(password, process.env.WRITER_PASSWORD ?? "")) {
role = "writer";
} else if (await checkPassword(password, process.env.READER_PASSWORD ?? "")) {
role = "reader";
}
if (!role) {
return reply.code(401).send({ error: "Invalid password" });
}
const token = app.jwt.sign({ role }, { expiresIn: "30d" });
return { token, role };
},
});
}

View File

@@ -0,0 +1,181 @@
import { FastifyInstance } from "fastify";
import { and, eq, sql } from "drizzle-orm";
import { db } from "../db";
import { letters, NewLetter } from "../db/schema";
import { verifyJwt } from "../middleware/auth";
const utcDate = () => new Date().toISOString().slice(0, 10);
// Effective date for a letter: prefer client-sent letter_date, fall back to UTC date of created_at
const effectiveDate = sql<string>`COALESCE(${letters.letterDate}, DATE(${letters.createdAt}))`;
export async function letterRoutes(app: FastifyInstance) {
app.addHook("onRequest", verifyJwt);
// GET /api/letters
app.get("/api/letters", async () => {
return db
.select({
id: letters.id,
letterDate: letters.letterDate,
createdAt: letters.createdAt,
deliverAt: letters.deliverAt,
readAt: letters.readAt,
})
.from(letters)
.where(sql`(${letters.deliverAt} IS NULL OR ${letters.deliverAt} <= ${utcDate()})`)
.orderBy(sql`COALESCE(${letters.letterDate}, DATE(${letters.createdAt})) DESC`);
});
// GET /api/letters/calendar
app.get("/api/letters/calendar", async () => {
const rows = await db
.select({ id: letters.id, letterDate: letters.letterDate, createdAt: letters.createdAt })
.from(letters)
.where(sql`(${letters.deliverAt} IS NULL OR ${letters.deliverAt} <= ${utcDate()})`);
return rows.map((r) => ({
id: r.id,
date: r.letterDate ?? r.createdAt.slice(0, 10),
}));
});
// GET /api/streak
app.get("/api/streak", async () => {
const rows = await db
.select({ date: effectiveDate })
.from(letters)
.where(sql`(${letters.deliverAt} IS NULL OR ${letters.deliverAt} <= ${utcDate()})`)
.orderBy(sql`COALESCE(${letters.letterDate}, DATE(${letters.createdAt})) DESC`);
const dates: string[] = [...new Set(rows.map((r: { date: string }) => r.date))];
let current = 0;
let longest = 0;
let streak = 0;
let prev: Date | null = null;
for (let i = 0; i < dates.length; i++) {
const d = new Date(dates[i]);
streak = prev === null ? 1 : (prev.getTime() - d.getTime()) / 86400000 === 1 ? streak + 1 : 1;
if (i === 0) current = streak;
longest = Math.max(longest, streak);
prev = d;
}
if (dates.length > 0) {
const diffFromToday = (new Date(utcDate()).getTime() - new Date(dates[0]).getTime()) / 86400000;
if (diffFromToday > 1) current = 0;
}
return { current, longest };
});
// GET /api/letters/:id
app.get<{ Params: { id: string } }>("/api/letters/:id", async (req, reply) => {
const id = parseInt(req.params.id, 10);
const rows = await db
.select()
.from(letters)
.where(
and(
eq(letters.id, id),
sql`(${letters.deliverAt} IS NULL OR ${letters.deliverAt} <= ${utcDate()})`
)
);
if (!rows[0]) return reply.code(404).send({ error: "Letter not found" });
// Mark as read when the reader opens a letter for the first time
const { role } = req.user as { role: string };
if (role === "reader" && !rows[0].readAt) {
await db
.update(letters)
.set({ readAt: new Date().toISOString() })
.where(eq(letters.id, id));
}
return rows[0];
});
// POST /api/letters
app.post<{ Body: NewLetter & { letterDate?: string } }>("/api/letters", {
schema: {
body: {
type: "object",
required: ["content"],
properties: {
content: { type: "string" },
letterDate: { type: "string" },
deliverAt: { type: "string", nullable: true },
},
},
},
handler: async (req, reply) => {
const { role } = req.user as { role: string };
if (role !== "writer") {
return reply.code(403).send({ error: "Readers cannot create letters" });
}
const { content, deliverAt, letterDate } = req.body;
const checkDate = letterDate ?? utcDate();
const dayCheck = sql`COALESCE(${letters.letterDate}, DATE(${letters.createdAt})) = ${checkDate}`;
const existing = await db.select({ id: letters.id }).from(letters).where(dayCheck);
if (existing[0]) {
return reply.code(409).send({ error: "Already wrote today", id: existing[0].id });
}
const inserted = await db
.insert(letters)
.values({ content, letterDate: letterDate ?? null, deliverAt: deliverAt ?? null })
.returning({ id: letters.id });
return reply.code(201).send({ id: inserted[0].id });
},
});
// PUT /api/letters/:id
app.put<{ Params: { id: string }; Body: Partial<NewLetter> & { clientDate?: string } }>("/api/letters/:id", {
schema: {
body: {
type: "object",
properties: {
content: { type: "string" },
deliverAt: { type: "string", nullable: true },
clientDate: { type: "string" },
},
},
},
handler: async (req, reply) => {
const { role } = req.user as { role: string };
if (role !== "writer") {
return reply.code(403).send({ error: "Readers cannot edit letters" });
}
const id = parseInt(req.params.id, 10);
const rows = await db
.select({ letterDate: letters.letterDate, createdAt: letters.createdAt })
.from(letters)
.where(eq(letters.id, id));
if (!rows[0]) return reply.code(404).send({ error: "Letter not found" });
const storedDate = rows[0].letterDate ?? rows[0].createdAt.slice(0, 10);
const clientToday = req.body.clientDate ?? utcDate();
if (storedDate !== clientToday) {
return reply.code(403).send({ error: "Can only edit today's letter" });
}
const { content, deliverAt } = req.body;
await db
.update(letters)
.set({
...(content !== undefined && { content }),
...(deliverAt !== undefined && { deliverAt }),
})
.where(eq(letters.id, id));
return { ok: true };
},
});
}

15
backend/tsconfig.json Normal file
View File

@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "CommonJS",
"moduleResolution": "node",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}