Add 'packages/backend/' from commit 'acce648ee5e7e3a3006451e637c0db654820cc48'

git-subtree-dir: packages/backend
git-subtree-mainline: d0babd62af
git-subtree-split: acce648ee5
This commit is contained in:
Oliver Bryan
2025-12-13 20:21:47 +00:00
33 changed files with 1306 additions and 0 deletions

View File

@@ -0,0 +1,22 @@
import "dotenv/config";
import { drizzle } from "drizzle-orm/node-postgres";
if (!process.env.DATABASE_URL) {
throw new Error("DATABASE_URL is not set in environment variables");
}
export const db = drizzle({
connection: {
connectionString: process.env.DATABASE_URL,
},
});
export const testDB = async () => {
try {
await db.execute("SELECT 1;");
console.log("db connected");
} catch (err) {
console.log("db down");
process.exit();
}
};

View File

@@ -0,0 +1,3 @@
export * from "./users";
export * from "./projects";
export * from "./issues";

View File

@@ -0,0 +1,59 @@
import { eq, sql, and } from "drizzle-orm";
import { db } from "../client";
import { Issue } from "../schema";
export async function createIssue(projectId: number, title: string, description: string) {
// prevents two issues with the same unique number
return await db.transaction(async (tx) => {
// raw sql for speed
// most recent issue from project
const [lastIssue] = await tx
.select({ max: sql<number>`MAX(${Issue.number})` })
.from(Issue)
.where(eq(Issue.projectId, projectId));
const nextNumber = (lastIssue?.max || 0) + 1;
// 2. create new issue
const [newIssue] = await tx
.insert(Issue)
.values({
projectId,
title,
description,
number: nextNumber,
})
.returning();
return newIssue;
});
}
export async function deleteIssue(id: number) {
return await db.delete(Issue).where(eq(Issue.id, id));
}
export async function updateIssue(id: number, updates: { title?: string; description?: string }) {
return await db.update(Issue).set(updates).where(eq(Issue.id, id)).returning();
}
export async function getIssues() {
return await db.select().from(Issue);
}
export async function getIssuesByProject(projectId: number) {
return await db.select().from(Issue).where(eq(Issue.projectId, projectId));
}
export async function getIssueByID(id: number) {
const [issue] = await db.select().from(Issue).where(eq(Issue.id, id));
return issue;
}
export async function getIssueByNumber(projectId: number, number: number) {
const [issue] = await db
.select()
.from(Issue)
.where(and(eq(Issue.projectId, projectId), eq(Issue.number, number)));
return issue;
}

View File

@@ -0,0 +1,40 @@
import { eq } from "drizzle-orm";
import { db } from "../client";
import { Issue, Project, User } from "../schema";
export async function createProject(blob: string, name: string, ownerId: number) {
const [project] = await db
.insert(Project)
.values({
blob,
name,
ownerId,
})
.returning();
return project;
}
export async function updateProject(
projectId: number,
updates: { blob?: string; name?: string; ownerId?: number },
) {
const [project] = await db.update(Project).set(updates).where(eq(Project.id, projectId)).returning();
return project;
}
export async function deleteProject(projectId: number) {
// delete all of the project's issues first
await db.delete(Issue).where(eq(Issue.projectId, projectId));
// delete actual project
await db.delete(Project).where(eq(Project.id, projectId));
}
export async function getProjectByID(projectId: number) {
const [project] = await db.select().from(Project).where(eq(Project.id, projectId));
return project;
}
export async function getProjectByBlob(projectBlob: string) {
const [project] = await db.select().from(Project).where(eq(Project.blob, projectBlob));
return project;
}

View File

@@ -0,0 +1,18 @@
import { eq } from "drizzle-orm";
import { db } from "../client";
import { User } from "../schema";
export async function createUser(name: string, username: string) {
const [user] = await db.insert(User).values({ name, username }).returning();
return user;
}
export async function getUserById(id: number) {
const [user] = await db.select().from(User).where(eq(User.id, id));
return user;
}
export async function getUserByUsername(username: string) {
const [user] = await db.select().from(User).where(eq(User.username, username));
return user;
}

View File

@@ -0,0 +1,36 @@
import { integer, pgTable, uniqueIndex, varchar } from "drizzle-orm/pg-core";
export const User = pgTable("User", {
id: integer().primaryKey().generatedAlwaysAsIdentity(),
name: varchar({ length: 256 }).notNull(),
username: varchar({ length: 32 }).notNull().unique(),
});
export const Project = pgTable("Project", {
id: integer().primaryKey().generatedAlwaysAsIdentity(),
blob: varchar({ length: 4 }).notNull(),
name: varchar({ length: 256 }).notNull(),
ownerId: integer()
.notNull()
.references(() => User.id),
});
export const Issue = pgTable(
"Issue",
{
id: integer().primaryKey().generatedAlwaysAsIdentity(),
projectId: integer()
.notNull()
.references(() => Project.id),
number: integer("number").notNull(),
title: varchar({ length: 256 }).notNull(),
description: varchar({ length: 2048 }).notNull(),
},
(t) => [
// ensures unique numbers per project
// you can have Issue 1 in PROJ and Issue 1 in TEST, but not two Issue 1s in PROJ
uniqueIndex("unique_project_issue_number").on(t.projectId, t.number),
],
);

View File

@@ -0,0 +1,108 @@
import { db, testDB } from "./db/client";
import { User } from "./db/schema";
import { routes } from "./routes";
import { createDemoData } from "./utils";
const DEV = process.argv.find((arg) => ["--dev", "--developer", "-d"].includes(arg.toLowerCase())) != null;
const PORT = process.argv.find((arg) => arg.toLowerCase().startsWith("--port="))?.split("=")[1] || 0;
type RouteHandler<T extends Request = Request> = (req: T) => Response | Promise<Response>;
const CORS_ALLOWED_ORIGINS = (process.env.CORS_ORIGIN ?? "http://localhost:1420")
.split(",")
.map((origin) => origin.trim())
.filter(Boolean);
const CORS_ALLOW_METHODS = process.env.CORS_ALLOW_METHODS ?? "GET,POST,PUT,PATCH,DELETE,OPTIONS";
const CORS_ALLOW_HEADERS_DEFAULT = process.env.CORS_ALLOW_HEADERS ?? "Content-Type, Authorization";
const CORS_MAX_AGE = process.env.CORS_MAX_AGE ?? "86400";
const getCorsAllowOrigin = (req: Request) => {
const requestOrigin = req.headers.get("Origin");
if (!requestOrigin) {
return "*";
}
if (CORS_ALLOWED_ORIGINS.includes("*")) {
return "*";
}
if (CORS_ALLOWED_ORIGINS.includes(requestOrigin)) {
return requestOrigin;
}
return null;
};
const buildCorsHeaders = (req: Request) => {
const headers = new Headers();
const allowOrigin = getCorsAllowOrigin(req);
if (allowOrigin) {
headers.set("Access-Control-Allow-Origin", allowOrigin);
if (allowOrigin !== "*") {
headers.set("Vary", "Origin");
}
}
headers.set("Access-Control-Allow-Methods", CORS_ALLOW_METHODS);
const requestedHeaders = req.headers.get("Access-Control-Request-Headers");
headers.set("Access-Control-Allow-Headers", requestedHeaders || CORS_ALLOW_HEADERS_DEFAULT);
headers.set("Access-Control-Max-Age", CORS_MAX_AGE);
return headers;
};
const withCors = <T extends Request>(handler: RouteHandler<T>): RouteHandler<T> => {
return async (req: T) => {
const corsHeaders = buildCorsHeaders(req);
if (req.method === "OPTIONS") {
return new Response(null, { status: 204, headers: corsHeaders });
}
const res = await handler(req);
const wrapped = new Response(res.body, res);
corsHeaders.forEach((value, key) => {
wrapped.headers.set(key, value);
});
return wrapped;
};
};
const main = async () => {
const server = Bun.serve({
port: Number(PORT),
routes: {
"/": withCors(() => new Response(`title: eussi\ndev-mode: ${DEV}\nport: ${PORT}`)),
"/issue/create": withCors(routes.issueCreate),
"/issue/update": withCors(routes.issueUpdate),
"/issue/delete": withCors(routes.issueDelete),
"/issues/:projectBlob": withCors(routes.issuesInProject),
"/issues/all": withCors(routes.issues),
"/project/create": withCors(routes.projectCreate),
"/project/update": withCors(routes.projectUpdate),
"/project/delete": withCors(routes.projectDelete),
},
});
console.log(`eussi (issue server) listening on ${server.url}`);
await testDB();
if (DEV) {
const users = await db.select().from(User);
if (users.length === 0) {
console.log("creating demo data...");
await createDemoData();
console.log("demo data created");
}
}
};
main();

View File

@@ -0,0 +1,22 @@
import issueCreate from "./issue/create";
import issueDelete from "./issue/delete";
import issueUpdate from "./issue/update";
import issuesInProject from "./issues/[projectBlob]";
import issues from "./issues/all";
import projectCreate from "./project/create";
import projectUpdate from "./project/update";
import projectDelete from "./project/delete";
export const routes = {
issueCreate,
issueDelete,
issueUpdate,
issuesInProject,
issues,
projectCreate,
projectUpdate,
projectDelete,
};

View File

@@ -0,0 +1,30 @@
import type { BunRequest } from "bun";
import { createIssue, getProjectByID, getProjectByBlob } from "../../db/queries";
// /issue/create?projectId=1&title=Testing&description=Description
// OR
// /issue/create?projectBlob=projectBlob&title=Testing&description=Description
export default async function issueCreate(req: BunRequest) {
const url = new URL(req.url);
const projectId = url.searchParams.get("projectId");
const projectBlob = url.searchParams.get("projectBlob");
let project = null;
if (projectId) {
project = await getProjectByID(Number(projectId));
} else if (projectBlob) {
project = await getProjectByBlob(projectBlob);
} else {
return new Response("missing project blob or project id", { status: 400 });
}
if (!project) {
return new Response(`project not found: provided ${projectId ?? projectBlob}`, { status: 404 });
}
const title = url.searchParams.get("title") || "Untitled Issue";
const description = url.searchParams.get("description") || "";
const issue = await createIssue(project.id, title, description);
return Response.json(issue);
}

View File

@@ -0,0 +1,18 @@
import type { BunRequest } from "bun";
import { deleteIssue } from "../../db/queries";
// /issue/delete?id=1
export default async function issueDelete(req: BunRequest) {
const url = new URL(req.url);
const id = url.searchParams.get("id");
if (!id) {
return new Response("missing issue id", { status: 400 });
}
const result = await deleteIssue(Number(id));
if (result.rowCount === 0) {
return new Response(`no issue with id ${id} found`, { status: 404 });
}
return new Response(`issue with id ${id} deleted`, { status: 200 });
}

View File

@@ -0,0 +1,24 @@
import type { BunRequest } from "bun";
import { updateIssue } from "../../db/queries";
// /issue/update?id=1&title=Testing&description=Description
export default async function issueUpdate(req: BunRequest) {
const url = new URL(req.url);
const id = url.searchParams.get("id");
if (!id) {
return new Response("missing issue id", { status: 400 });
}
const title = url.searchParams.get("title") || undefined;
const description = url.searchParams.get("description") || undefined;
if (!title && !description) {
return new Response("no updates provided", { status: 400 });
}
const issue = await updateIssue(Number(id), {
title,
description,
});
return Response.json(issue);
}

View File

@@ -0,0 +1,14 @@
import type { BunRequest } from "bun";
import { getIssuesByProject, getProjectByBlob } from "../../db/queries";
export default async function issuesInProject(req: BunRequest<"/issues/:projectBlob">) {
const { projectBlob } = req.params;
const project = await getProjectByBlob(projectBlob);
if (!project) {
return new Response(`project not found: provided ${projectBlob}`, { status: 404 });
}
const issues = await getIssuesByProject(project.id);
return Response.json(issues);
}

View File

@@ -0,0 +1,8 @@
import type { BunRequest } from "bun";
import { getIssues } from "../../db/queries";
export default async function issuesAll(req: BunRequest) {
const issues = await getIssues();
return Response.json(issues);
}

View File

@@ -0,0 +1,32 @@
import type { BunRequest } from "bun";
import { createProject, getUserById, getProjectByBlob } from "../../db/queries";
// /project/create?blob=BLOB&name=Testing&ownerId=1
export default async function projectCreate(req: BunRequest) {
const url = new URL(req.url);
const blob = url.searchParams.get("blob");
const name = url.searchParams.get("name");
const ownerId = url.searchParams.get("ownerId");
if (!blob || !name || !ownerId) {
return new Response(
`missing parameters: ${!blob ? "blob " : ""}${!name ? "name " : ""}${!ownerId ? "ownerId" : ""}`,
{ status: 400 },
);
}
// check if project with blob already exists
const existingProject = await getProjectByBlob(blob);
if (existingProject) {
return new Response(`project with blob ${blob} already exists`, { status: 400 });
}
const owner = await getUserById(parseInt(ownerId, 10));
if (!owner) {
return new Response(`owner with id ${ownerId} not found`, { status: 404 });
}
const project = await createProject(blob, name, owner.id);
return Response.json(project);
}

View File

@@ -0,0 +1,21 @@
import type { BunRequest } from "bun";
import { getProjectByID, deleteProject } from "../../db/queries";
// /project/delete?id=1
export default async function projectDelete(req: BunRequest) {
const url = new URL(req.url);
const id = url.searchParams.get("id");
if (!id) {
return new Response(`project id is required`, { status: 400 });
}
const existingProject = await getProjectByID(Number(id));
if (!existingProject) {
return new Response(`project with id ${id} does not exist`, { status: 404 });
}
await deleteProject(Number(id));
return new Response(`project with id ${id} deleted successfully`, { status: 200 });
}

View File

@@ -0,0 +1,44 @@
import type { BunRequest } from "bun";
import { getProjectByBlob, getProjectByID, getUserById, updateProject } from "../../db/queries";
// /project/update?id=1&blob=NEW&name=new%20name&ownerId=1
export default async function projectUpdate(req: BunRequest) {
const url = new URL(req.url);
const id = url.searchParams.get("id");
const blob = url.searchParams.get("blob") || undefined;
const name = url.searchParams.get("name") || undefined;
const ownerId = url.searchParams.get("ownerId") || undefined;
if (!id) {
return new Response(`project id is required`, { status: 400 });
}
const existingProject = await getProjectByID(Number(id));
if (!existingProject) {
return new Response(`project with id ${id} does not exist`, { status: 404 });
}
if (!blob && !name && !ownerId) {
return new Response(`at least one of blob, name, or ownerId must be provided`, {
status: 400,
});
}
const projectWithBlob = blob ? await getProjectByBlob(blob) : null;
if (projectWithBlob && projectWithBlob.id !== Number(id)) {
return new Response(`a project with blob "${blob}" already exists`, { status: 400 });
}
const newOwner = ownerId ? await getUserById(Number(ownerId)) : null;
if (ownerId && !newOwner) {
return new Response(`user with id ${ownerId} does not exist`, { status: 400 });
}
const project = await updateProject(Number(id), {
blob: blob,
name: name,
ownerId: newOwner?.id,
});
return Response.json(project);
}

View File

@@ -0,0 +1,24 @@
import { createIssue, createProject, createUser } from "./db/queries";
export const createDemoData = async () => {
const user = await createUser("Demo User", "demo_user");
if (!user) {
throw new Error("failed to create demo user");
}
const projectNames = ["PROJ", "TEST", "SAMPLE"];
for (const name of projectNames) {
const project = await createProject(name.slice(0, 4), name, user.id);
if (!project) {
throw new Error(`failed to create demo project: ${name}`);
}
for (let i = 1; i <= 5; i++) {
await createIssue(
project.id,
`Issue ${i} in ${name}`,
`This is a description for issue ${i} in ${name}.`,
);
}
}
};