mirror of
https://github.com/hex248/sprint.git
synced 2026-02-08 10:33:01 +00:00
Add 'packages/backend/' from commit 'acce648ee5e7e3a3006451e637c0db654820cc48'
git-subtree-dir: packages/backend git-subtree-mainline:d0babd62afgit-subtree-split:acce648ee5
This commit is contained in:
22
packages/backend/src/db/client.ts
Normal file
22
packages/backend/src/db/client.ts
Normal 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();
|
||||
}
|
||||
};
|
||||
3
packages/backend/src/db/queries/index.ts
Normal file
3
packages/backend/src/db/queries/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./users";
|
||||
export * from "./projects";
|
||||
export * from "./issues";
|
||||
59
packages/backend/src/db/queries/issues.ts
Normal file
59
packages/backend/src/db/queries/issues.ts
Normal 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;
|
||||
}
|
||||
40
packages/backend/src/db/queries/projects.ts
Normal file
40
packages/backend/src/db/queries/projects.ts
Normal 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;
|
||||
}
|
||||
18
packages/backend/src/db/queries/users.ts
Normal file
18
packages/backend/src/db/queries/users.ts
Normal 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;
|
||||
}
|
||||
36
packages/backend/src/db/schema.ts
Normal file
36
packages/backend/src/db/schema.ts
Normal 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),
|
||||
],
|
||||
);
|
||||
108
packages/backend/src/index.ts
Normal file
108
packages/backend/src/index.ts
Normal 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();
|
||||
22
packages/backend/src/routes/index.ts
Normal file
22
packages/backend/src/routes/index.ts
Normal 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,
|
||||
};
|
||||
30
packages/backend/src/routes/issue/create.ts
Normal file
30
packages/backend/src/routes/issue/create.ts
Normal 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);
|
||||
}
|
||||
18
packages/backend/src/routes/issue/delete.ts
Normal file
18
packages/backend/src/routes/issue/delete.ts
Normal 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 });
|
||||
}
|
||||
24
packages/backend/src/routes/issue/update.ts
Normal file
24
packages/backend/src/routes/issue/update.ts
Normal 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);
|
||||
}
|
||||
14
packages/backend/src/routes/issues/[projectBlob].ts
Normal file
14
packages/backend/src/routes/issues/[projectBlob].ts
Normal 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);
|
||||
}
|
||||
8
packages/backend/src/routes/issues/all.ts
Normal file
8
packages/backend/src/routes/issues/all.ts
Normal 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);
|
||||
}
|
||||
32
packages/backend/src/routes/project/create.ts
Normal file
32
packages/backend/src/routes/project/create.ts
Normal 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);
|
||||
}
|
||||
21
packages/backend/src/routes/project/delete.ts
Normal file
21
packages/backend/src/routes/project/delete.ts
Normal 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 });
|
||||
}
|
||||
44
packages/backend/src/routes/project/update.ts
Normal file
44
packages/backend/src/routes/project/update.ts
Normal 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);
|
||||
}
|
||||
24
packages/backend/src/utils.ts
Normal file
24
packages/backend/src/utils.ts
Normal 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}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user