initial commit
This commit is contained in:
3273
backend/package-lock.json
generated
Normal file
3273
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
26
backend/package.json
Normal file
26
backend/package.json
Normal 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
40
backend/src/db/index.ts
Normal 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
17
backend/src/db/schema.ts
Normal 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
39
backend/src/index.ts
Normal 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);
|
||||
});
|
||||
9
backend/src/middleware/auth.ts
Normal file
9
backend/src/middleware/auth.ts
Normal 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" });
|
||||
}
|
||||
}
|
||||
38
backend/src/routes/auth.ts
Normal file
38
backend/src/routes/auth.ts
Normal 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 };
|
||||
},
|
||||
});
|
||||
}
|
||||
181
backend/src/routes/letters.ts
Normal file
181
backend/src/routes/letters.ts
Normal 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
15
backend/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Reference in New Issue
Block a user