This commit is contained in:
admin
2026-09-03 22:02:52 +09:00
commit 659725de15
90 changed files with 2840 additions and 0 deletions
+19
View File
@@ -0,0 +1,19 @@
{
"name": "@orderops/api",
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "bun --watch src/server.ts",
"start": "bun run src/server.ts",
"check": "bun run tsc -p tsconfig.json --noEmit",
"seed": "bun run src/db/run-seed.ts",
"seed:user": "bun run src/users/run-seed-user.ts",
"test": "bun test"
},
"dependencies": {
"@fastify/cors": "11.3.0",
"@orderops/shared": "workspace:*",
"drizzle-orm": "0.45.2",
"fastify": "5.12.1"
}
}
+28
View File
@@ -0,0 +1,28 @@
import cors from "@fastify/cors";
import Fastify, { type FastifyInstance } from "fastify";
import { registerAuthRoutes } from "./auth/auth-routes";
import { createDatabaseContext, type DatabaseContext } from "./db/database";
import { registerHealthRoute } from "./health/health-route";
import { registerOrderRoutes } from "./orders/order-routes";
export interface AppWithContext extends FastifyInstance {
dbContext: DatabaseContext;
}
export async function buildApp(databasePath?: string): Promise<AppWithContext> {
const db = await createDatabaseContext(databasePath);
const app = Fastify({ logger: true }) as unknown as AppWithContext;
app.dbContext = db;
app.addHook("onClose", async () => {
db.sqlite.close();
});
await app.register(cors, { origin: true });
await app.register(registerHealthRoute, { prefix: "/api" });
await app.register(registerAuthRoutes, { prefix: "/api", db });
await app.register(registerOrderRoutes, { prefix: "/api", db });
return app;
}
+48
View File
@@ -0,0 +1,48 @@
import { and, eq } from "drizzle-orm";
import type { DatabaseContext } from "../db/database";
import { sessions, users } from "../db/schema";
export async function createSession(context: DatabaseContext, userId: string, expiresAt: string) {
const session = {
id: crypto.randomUUID(),
userId,
createdAt: new Date().toISOString(),
expiresAt
};
await context.db.insert(sessions).values(session);
return session;
}
export async function findSessionUser(context: DatabaseContext, sessionId: string) {
const [row] = await context.db
.select({ session: sessions, user: users })
.from(sessions)
.innerJoin(users, eq(sessions.userId, users.id))
.where(eq(sessions.id, sessionId));
if (!row) {
return null;
}
if (new Date(row.session.expiresAt).getTime() <= Date.now()) {
await deleteSession(context, sessionId);
return null;
}
return {
id: row.user.id,
username: row.user.username,
name: row.user.name,
role: row.user.role as "customer" | "employee"
};
}
export async function deleteSession(context: DatabaseContext, sessionId: string) {
await context.db.delete(sessions).where(eq(sessions.id, sessionId));
}
export async function deleteUserSessions(context: DatabaseContext, userId: string) {
await context.db.delete(sessions).where(eq(sessions.userId, userId));
}
+52
View File
@@ -0,0 +1,52 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import type { AppWithContext } from "../app";
import { buildApp } from "../app";
let app: AppWithContext;
function firstHeader(value: string | string[] | undefined) {
return Array.isArray(value) ? value[0] : value ?? "";
}
beforeEach(async () => {
app = await buildApp(":memory:");
});
afterEach(async () => {
await app.close();
});
describe("auth routes", () => {
test("returns null current user when logged out", async () => {
const response = await app.inject({ method: "GET", url: "/api/me" });
expect(response.statusCode).toBe(200);
expect(response.json().user).toBeNull();
});
test("logs in seeded customer by username", async () => {
const response = await app.inject({ method: "POST", url: "/api/login", payload: { username: "john-smith" } });
expect(response.statusCode).toBe(200);
expect(response.json().user.username).toBe("john-smith");
expect(firstHeader(response.headers["set-cookie"])).toContain("orderops_session=");
});
test("returns current user from session cookie", async () => {
const login = await app.inject({ method: "POST", url: "/api/login", payload: { username: "john-smith" } });
const cookie = firstHeader(login.headers["set-cookie"]);
const response = await app.inject({ method: "GET", url: "/api/me", headers: { cookie } });
expect(response.statusCode).toBe(200);
expect(response.json().user.role).toBe("customer");
});
test("logs out and clears cookie", async () => {
const login = await app.inject({ method: "POST", url: "/api/login", payload: { username: "john-smith" } });
const cookie = firstHeader(login.headers["set-cookie"]);
const response = await app.inject({ method: "POST", url: "/api/logout", headers: { cookie } });
expect(response.statusCode).toBe(200);
expect(firstHeader(response.headers["set-cookie"])).toContain("Max-Age=0");
});
});
+55
View File
@@ -0,0 +1,55 @@
import type { FastifyPluginAsync } from "fastify";
import type { DatabaseContext } from "../db/database";
import { createSession, deleteSession } from "./auth-repository";
import { clearSessionCookie, createSessionCookie, readSessionCookie } from "./session-cookie";
import { getCurrentUser } from "./current-user";
import { findUserByUsername } from "../users/user-repository";
interface AuthRouteOptions {
db: DatabaseContext;
}
interface LoginBody {
username: string;
}
export const registerAuthRoutes: FastifyPluginAsync<AuthRouteOptions> = async (app, options) => {
app.get("/me", async (request) => ({ user: await getCurrentUser(options.db, request.headers.cookie) }));
app.post<{ Body: LoginBody }>("/login", {
schema: {
body: {
type: "object",
required: ["username"],
properties: {
username: { type: "string", minLength: 1 }
}
}
}
}, async (request, reply) => {
const user = await findUserByUsername(options.db, request.body.username.trim());
if (!user) {
return reply.code(401).send({ message: "Invalid username." });
}
const expiresAt = new Date(Date.now() + 1000 * 60 * 60 * 24 * 7).toISOString();
const session = await createSession(options.db, user.id, expiresAt);
reply.header("set-cookie", createSessionCookie(session.id, expiresAt));
return { user };
});
app.post("/logout", async (request, reply) => {
const sessionId = readSessionCookie(request.headers.cookie);
if (sessionId) {
await deleteSession(options.db, sessionId);
}
reply.header("set-cookie", clearSessionCookie());
return { ok: true };
});
};
+31
View File
@@ -0,0 +1,31 @@
import type { AuthUser } from "@orderops/shared";
import type { FastifyReply, FastifyRequest } from "fastify";
import type { DatabaseContext } from "../db/database";
import { findSessionUser } from "./auth-repository";
import { readSessionCookie } from "./session-cookie";
export async function getCurrentUser(context: DatabaseContext, cookieHeader?: string) {
const sessionId = readSessionCookie(cookieHeader);
if (!sessionId) {
return null;
}
return findSessionUser(context, sessionId);
}
export async function requireEmployeeUser(context: DatabaseContext, request: FastifyRequest, reply: FastifyReply): Promise<AuthUser | null> {
const user = await getCurrentUser(context, request.headers.cookie);
if (!user) {
await reply.code(401).send({ message: "Authentication required." });
return null;
}
if (user.role !== "employee") {
await reply.code(403).send({ message: "Employee access required." });
return null;
}
return user;
}
+25
View File
@@ -0,0 +1,25 @@
const sessionCookieName = "orderops_session";
export function readSessionCookie(cookieHeader?: string) {
if (!cookieHeader) {
return null;
}
for (const cookie of cookieHeader.split(";")) {
const [name, ...valueParts] = cookie.trim().split("=");
if (name === sessionCookieName) {
return valueParts.join("=") || null;
}
}
return null;
}
export function createSessionCookie(sessionId: string, expiresAt: string) {
return `${sessionCookieName}=${sessionId}; Path=/; HttpOnly; SameSite=Lax; Expires=${new Date(expiresAt).toUTCString()}`;
}
export function clearSessionCookie() {
return `${sessionCookieName}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`;
}
+28
View File
@@ -0,0 +1,28 @@
import { Database } from "bun:sqlite";
import { drizzle, type BunSQLiteDatabase } from "drizzle-orm/bun-sqlite";
import * as schema from "./schema";
import { initializeDatabase } from "./init-db";
import { ensureDatabaseDirectory, getDefaultDatabasePath } from "./paths";
import { seedDatabase } from "./seed-db";
export interface DatabaseContext {
db: BunSQLiteDatabase<typeof schema>;
sqlite: Database;
databasePath: string;
}
export async function createDatabaseContext(databasePath = getDefaultDatabasePath()) {
ensureDatabaseDirectory(databasePath);
const sqlite = new Database(databasePath, { create: true });
initializeDatabase(sqlite);
const db = drizzle(sqlite, { schema });
await seedDatabase(db);
return {
db,
sqlite,
databasePath
} satisfies DatabaseContext;
}
+122
View File
@@ -0,0 +1,122 @@
import type { Database } from "bun:sqlite";
function tableExists(sqlite: Database, tableName: string) {
return Boolean(sqlite.query("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?").get(tableName));
}
function columnExists(sqlite: Database, tableName: string, columnName: string) {
if (!tableExists(sqlite, tableName)) {
return false;
}
return sqlite
.query(`PRAGMA table_info(${tableName})`)
.all()
.some((column) => typeof column === "object" && column !== null && "name" in column && column.name === columnName);
}
function resetLegacySchema(sqlite: Database) {
sqlite.exec(`
PRAGMA foreign_keys = OFF;
DROP TABLE IF EXISTS sessions;
DROP TABLE IF EXISTS audit_events;
DROP TABLE IF EXISTS refunds;
DROP TABLE IF EXISTS order_items;
DROP TABLE IF EXISTS orders;
DROP TABLE IF EXISTS inventory;
DROP TABLE IF EXISTS products;
DROP TABLE IF EXISTS addresses;
DROP TABLE IF EXISTS users;
DROP TABLE IF EXISTS customers;
DROP TABLE IF EXISTS employees;
PRAGMA foreign_keys = ON;
`);
}
function needsLegacyReset(sqlite: Database) {
return tableExists(sqlite, "customers") || tableExists(sqlite, "employees") || columnExists(sqlite, "audit_events", "employee_id");
}
export function initializeDatabase(sqlite: Database) {
if (needsLegacyReset(sqlite)) {
resetLegacySchema(sqlite);
}
sqlite.exec(`
PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE,
role TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id),
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS addresses (
id TEXT PRIMARY KEY,
full_name TEXT NOT NULL,
line_1 TEXT NOT NULL,
line_2 TEXT,
city TEXT NOT NULL,
state TEXT NOT NULL,
postal_code TEXT NOT NULL,
country TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS products (
id TEXT PRIMARY KEY,
sku TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
price_cents INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS inventory (
product_id TEXT PRIMARY KEY REFERENCES products(id),
available_quantity INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS orders (
id TEXT PRIMARY KEY,
order_number TEXT NOT NULL UNIQUE,
customer_id TEXT NOT NULL REFERENCES users(id),
shipping_address_id TEXT NOT NULL REFERENCES addresses(id),
status TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS order_items (
id TEXT PRIMARY KEY,
order_id TEXT NOT NULL REFERENCES orders(id),
product_id TEXT NOT NULL REFERENCES products(id),
quantity INTEGER NOT NULL,
unit_price_cents INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS refunds (
id TEXT PRIMARY KEY,
order_id TEXT NOT NULL REFERENCES orders(id),
amount_cents INTEGER NOT NULL,
reason TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS audit_events (
id TEXT PRIMARY KEY,
order_id TEXT NOT NULL REFERENCES orders(id),
actor TEXT NOT NULL,
type TEXT NOT NULL,
message TEXT NOT NULL,
created_at TEXT NOT NULL
);
`);
}
+15
View File
@@ -0,0 +1,15 @@
import { mkdirSync } from "node:fs";
import { dirname } from "node:path";
import { fileURLToPath } from "node:url";
export function getDefaultDatabasePath() {
return fileURLToPath(new URL("../../data/orderops.sqlite", import.meta.url));
}
export function ensureDatabaseDirectory(databasePath: string) {
if (databasePath === ":memory:") {
return;
}
mkdirSync(dirname(databasePath), { recursive: true });
}
+6
View File
@@ -0,0 +1,6 @@
import { createDatabaseContext } from "./database";
const context = await createDatabaseContext();
console.log(`Seeded ${context.databasePath}`);
context.sqlite.close();
+12
View File
@@ -0,0 +1,12 @@
import { text, sqliteTable } from "drizzle-orm/sqlite-core";
export const addresses = sqliteTable("addresses", {
id: text("id").primaryKey(),
fullName: text("full_name").notNull(),
line1: text("line_1").notNull(),
line2: text("line_2"),
city: text("city").notNull(),
state: text("state").notNull(),
postalCode: text("postal_code").notNull(),
country: text("country").notNull()
});
+11
View File
@@ -0,0 +1,11 @@
import { text, sqliteTable } from "drizzle-orm/sqlite-core";
import { orders } from "./orders";
export const auditEvents = sqliteTable("audit_events", {
id: text("id").primaryKey(),
orderId: text("order_id").notNull().references(() => orders.id),
actor: text("actor").notNull(),
type: text("type").notNull(),
message: text("message").notNull(),
createdAt: text("created_at").notNull()
});
+7
View File
@@ -0,0 +1,7 @@
import { text, sqliteTable } from "drizzle-orm/sqlite-core";
export const customers = sqliteTable("customers", {
id: text("id").primaryKey(),
name: text("name").notNull(),
email: text("email").notNull().unique()
});
+7
View File
@@ -0,0 +1,7 @@
import { text, sqliteTable } from "drizzle-orm/sqlite-core";
export const employees = sqliteTable("employees", {
id: text("id").primaryKey(),
name: text("name").notNull(),
email: text("email").notNull().unique()
});
+8
View File
@@ -0,0 +1,8 @@
export * from "./addresses";
export * from "./audit";
export * from "./inventory";
export * from "./orders";
export * from "./products";
export * from "./refunds";
export * from "./sessions";
export * from "./users";
+7
View File
@@ -0,0 +1,7 @@
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
import { products } from "./products";
export const inventory = sqliteTable("inventory", {
productId: text("product_id").primaryKey().references(() => products.id),
availableQuantity: integer("available_quantity").notNull()
});
+22
View File
@@ -0,0 +1,22 @@
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
import { addresses } from "./addresses";
import { products } from "./products";
import { users } from "./users";
export const orders = sqliteTable("orders", {
id: text("id").primaryKey(),
orderNumber: text("order_number").notNull().unique(),
customerId: text("customer_id").notNull().references(() => users.id),
shippingAddressId: text("shipping_address_id").notNull().references(() => addresses.id),
status: text("status").notNull(),
createdAt: text("created_at").notNull(),
updatedAt: text("updated_at").notNull()
});
export const orderItems = sqliteTable("order_items", {
id: text("id").primaryKey(),
orderId: text("order_id").notNull().references(() => orders.id),
productId: text("product_id").notNull().references(() => products.id),
quantity: integer("quantity").notNull(),
unitPriceCents: integer("unit_price_cents").notNull()
});
+8
View File
@@ -0,0 +1,8 @@
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
export const products = sqliteTable("products", {
id: text("id").primaryKey(),
sku: text("sku").notNull().unique(),
name: text("name").notNull(),
priceCents: integer("price_cents").notNull()
});
+10
View File
@@ -0,0 +1,10 @@
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
import { orders } from "./orders";
export const refunds = sqliteTable("refunds", {
id: text("id").primaryKey(),
orderId: text("order_id").notNull().references(() => orders.id),
amountCents: integer("amount_cents").notNull(),
reason: text("reason").notNull(),
createdAt: text("created_at").notNull()
});
+9
View File
@@ -0,0 +1,9 @@
import { text, sqliteTable } from "drizzle-orm/sqlite-core";
import { users } from "./users";
export const sessions = sqliteTable("sessions", {
id: text("id").primaryKey(),
userId: text("user_id").notNull().references(() => users.id),
createdAt: text("created_at").notNull(),
expiresAt: text("expires_at").notNull()
});
+10
View File
@@ -0,0 +1,10 @@
import { text, sqliteTable } from "drizzle-orm/sqlite-core";
export const users = sqliteTable("users", {
id: text("id").primaryKey(),
username: text("username").notNull().unique(),
name: text("name").notNull(),
email: text("email").notNull().unique(),
role: text("role").notNull(),
createdAt: text("created_at").notNull()
});
+81
View File
@@ -0,0 +1,81 @@
import { count } from "drizzle-orm";
import type { BunSQLiteDatabase } from "drizzle-orm/bun-sqlite";
import * as schema from "./schema";
import { addresses, auditEvents, inventory, orderItems, orders, products, refunds, users } from "./schema";
const now = "2025-01-15T10:00:00.000Z";
export async function seedDatabase(db: BunSQLiteDatabase<typeof schema>) {
const [row] = await db.select({ value: count() }).from(orders);
if ((row?.value ?? 0) > 0) {
return;
}
await db.insert(users).values([
{ id: "user_cust_1", username: "john-smith", name: "John Smith", email: "john.smith@example.com", role: "customer", createdAt: now },
{ id: "user_cust_2", username: "jane-doe", name: "Jane Doe", email: "jane.doe@example.com", role: "customer", createdAt: now },
{ id: "user_cust_3", username: "priya-patel", name: "Priya Patel", email: "priya.patel@example.com", role: "customer", createdAt: now },
{ id: "user_cust_4", username: "marco-ruiz", name: "Marco Ruiz", email: "marco.ruiz@example.com", role: "customer", createdAt: now },
{ id: "user_cust_5", username: "evelyn-brooks", name: "Evelyn Brooks", email: "evelyn.brooks@example.com", role: "customer", createdAt: now },
{ id: "user_cust_6", username: "sana-khan", name: "Sana Khan", email: "sana.khan@example.com", role: "customer", createdAt: now }
]);
await db.insert(addresses).values([
{ id: "addr_1", fullName: "John Smith", line1: "123 Market Street", line2: null, city: "San Francisco", state: "CA", postalCode: "94105", country: "US" },
{ id: "addr_2", fullName: "Jane Doe", line1: "500 State Street", line2: "Suite 8", city: "Chicago", state: "IL", postalCode: "60601", country: "US" },
{ id: "addr_3", fullName: "Priya Patel", line1: "18 Lake Avenue", line2: null, city: "Seattle", state: "WA", postalCode: "98101", country: "US" },
{ id: "addr_4", fullName: "Marco Ruiz", line1: "240 Harbor Road", line2: null, city: "Miami", state: "FL", postalCode: "33101", country: "US" },
{ id: "addr_5", fullName: "Evelyn Brooks", line1: "88 Elm Street", line2: "Apt 4B", city: "Boston", state: "MA", postalCode: "02108", country: "US" },
{ id: "addr_6", fullName: "Sana Khan", line1: "12 Cedar Lane", line2: null, city: "Austin", state: "TX", postalCode: "73301", country: "US" }
]);
await db.insert(products).values([
{ id: "prod_1", sku: "TSHIRT-RED-M", name: "Red T-Shirt / M", priceCents: 2500 },
{ id: "prod_2", sku: "MUG-BLACK", name: "Black Mug", priceCents: 1800 },
{ id: "prod_3", sku: "CAP-NAVY", name: "Navy Cap", priceCents: 2200 },
{ id: "prod_4", sku: "NOTEBOOK-GRID", name: "Grid Notebook", priceCents: 1200 }
]);
await db.insert(inventory).values([
{ productId: "prod_1", availableQuantity: 6 },
{ productId: "prod_2", availableQuantity: 10 },
{ productId: "prod_3", availableQuantity: 3 },
{ productId: "prod_4", availableQuantity: 14 }
]);
await db.insert(orders).values([
{ id: "order_1", orderNumber: "ORD-1001", customerId: "user_cust_1", shippingAddressId: "addr_1", status: "pending", createdAt: now, updatedAt: now },
{ id: "order_2", orderNumber: "ORD-1002", customerId: "user_cust_2", shippingAddressId: "addr_2", status: "processing", createdAt: "2025-01-12T09:30:00.000Z", updatedAt: "2025-01-13T08:15:00.000Z" },
{ id: "order_3", orderNumber: "ORD-1003", customerId: "user_cust_1", shippingAddressId: "addr_1", status: "shipped", createdAt: "2025-01-10T07:00:00.000Z", updatedAt: "2025-01-11T12:00:00.000Z" },
{ id: "order_4", orderNumber: "ORD-1004", customerId: "user_cust_3", shippingAddressId: "addr_3", status: "pending", createdAt: "2025-01-09T14:20:00.000Z", updatedAt: "2025-01-09T14:20:00.000Z" },
{ id: "order_5", orderNumber: "ORD-1005", customerId: "user_cust_4", shippingAddressId: "addr_4", status: "processing", createdAt: "2025-01-08T11:45:00.000Z", updatedAt: "2025-01-09T08:10:00.000Z" },
{ id: "order_6", orderNumber: "ORD-1006", customerId: "user_cust_5", shippingAddressId: "addr_5", status: "shipped", createdAt: "2025-01-06T18:00:00.000Z", updatedAt: "2025-01-07T16:30:00.000Z" },
{ id: "order_7", orderNumber: "ORD-1007", customerId: "user_cust_6", shippingAddressId: "addr_6", status: "pending", createdAt: "2025-01-05T10:10:00.000Z", updatedAt: "2025-01-05T10:10:00.000Z" }
]);
await db.insert(orderItems).values([
{ id: "item_1", orderId: "order_1", productId: "prod_1", quantity: 2, unitPriceCents: 2500 },
{ id: "item_2", orderId: "order_1", productId: "prod_2", quantity: 1, unitPriceCents: 1800 },
{ id: "item_3", orderId: "order_2", productId: "prod_3", quantity: 1, unitPriceCents: 2200 },
{ id: "item_4", orderId: "order_3", productId: "prod_2", quantity: 2, unitPriceCents: 1800 },
{ id: "item_5", orderId: "order_4", productId: "prod_4", quantity: 3, unitPriceCents: 1200 },
{ id: "item_6", orderId: "order_5", productId: "prod_1", quantity: 1, unitPriceCents: 2500 },
{ id: "item_7", orderId: "order_6", productId: "prod_4", quantity: 2, unitPriceCents: 1200 },
{ id: "item_8", orderId: "order_7", productId: "prod_3", quantity: 1, unitPriceCents: 2200 }
]);
await db.insert(refunds).values([
{ id: "refund_1", orderId: "order_6", amountCents: 1200, reason: "Customer reported damaged item.", createdAt: "2025-01-14T15:45:00.000Z" }
]);
await db.insert(auditEvents).values([
{ id: "audit_1", orderId: "order_1", actor: "System Import", type: "order.created", message: "Order created from storefront checkout.", createdAt: now },
{ id: "audit_2", orderId: "order_2", actor: "Warehouse Sync", type: "order.processing", message: "Warehouse started packing order.", createdAt: "2025-01-13T08:15:00.000Z" },
{ id: "audit_3", orderId: "order_3", actor: "Carrier Sync", type: "order.shipped", message: "Order handed off to carrier.", createdAt: "2025-01-11T12:00:00.000Z" },
{ id: "audit_4", orderId: "order_4", actor: "System Import", type: "order.created", message: "Order created from storefront checkout.", createdAt: "2025-01-09T14:20:00.000Z" },
{ id: "audit_5", orderId: "order_5", actor: "Warehouse Sync", type: "order.processing", message: "Pick list sent to fulfillment.", createdAt: "2025-01-09T08:10:00.000Z" },
{ id: "audit_6", orderId: "order_6", actor: "Refund Batch", type: "refund.created", message: "Partial refund issued for damaged notebook.", createdAt: "2025-01-14T15:45:00.000Z" },
{ id: "audit_7", orderId: "order_7", actor: "System Import", type: "order.created", message: "Order created from storefront checkout.", createdAt: "2025-01-05T10:10:00.000Z" }
]);
}
+5
View File
@@ -0,0 +1,5 @@
import type { FastifyPluginAsync } from "fastify";
export const registerHealthRoute: FastifyPluginAsync = async (app) => {
app.get("/health", async () => ({ status: "ok" }));
};
+37
View File
@@ -0,0 +1,37 @@
import type { DatabaseContext } from "../db/database";
import { canCancelOrder } from "./order-status";
import { appendAuditEvent, findOrderDetail, updateOrderStatus } from "./order-repository";
export async function cancelOrder(
context: DatabaseContext,
input: {
orderId: string;
actor: string;
reason?: string;
}
) {
const order = await findOrderDetail(context, input.orderId);
if (!order) {
return { statusCode: 404, message: "Order not found." } as const;
}
if (!canCancelOrder(order.status)) {
return { statusCode: 400, message: "Order cannot be cancelled for this order status." } as const;
}
const updatedAt = new Date().toISOString();
await updateOrderStatus(context, input.orderId, "cancelled", updatedAt);
await appendAuditEvent(context, {
orderId: input.orderId,
actor: input.actor,
type: "order.cancelled",
message: input.reason?.trim() ? `Cancelled order. Reason: ${input.reason.trim()}` : "Cancelled order.",
createdAt: updatedAt
});
return {
statusCode: 200,
order: await findOrderDetail(context, input.orderId)
} as const;
}
+6
View File
@@ -0,0 +1,6 @@
import type { DatabaseContext } from "../db/database";
import { findOrderDetail } from "./order-repository";
export function getOrder(context: DatabaseContext, orderId: string) {
return findOrderDetail(context, orderId);
}
+6
View File
@@ -0,0 +1,6 @@
import type { DatabaseContext } from "../db/database";
import { listOrderAuditEvents } from "./order-repository";
export function listAuditEvents(context: DatabaseContext, orderId: string) {
return listOrderAuditEvents(context, orderId);
}
+6
View File
@@ -0,0 +1,6 @@
import type { DatabaseContext } from "../db/database";
import { listOrderSummaries } from "./order-repository";
export function listOrders(context: DatabaseContext, query?: string) {
return listOrderSummaries(context, query);
}
+208
View File
@@ -0,0 +1,208 @@
import type { Address, AuditEvent, OrderDetail, OrderItemDetail, OrderSummary } from "@orderops/shared";
import { and, asc, desc, eq, inArray, or, sql } from "drizzle-orm";
import type { DatabaseContext } from "../db/database";
import { addresses, auditEvents, inventory, orderItems, orders, products, users } from "../db/schema";
function toAddress(row: typeof addresses.$inferSelect): Address {
return {
fullName: row.fullName,
line1: row.line1,
line2: row.line2,
city: row.city,
state: row.state,
postalCode: row.postalCode,
country: row.country
};
}
function toOrderItemDetail(row: {
item: typeof orderItems.$inferSelect;
product: typeof products.$inferSelect;
inventory: typeof inventory.$inferSelect | null;
}): OrderItemDetail {
return {
id: row.item.id,
productId: row.product.id,
sku: row.product.sku,
productName: row.product.name,
quantity: row.item.quantity,
unitPrice: {
amountCents: row.item.unitPriceCents,
currency: "USD"
},
availableQuantity: row.inventory?.availableQuantity ?? 0
};
}
export async function listOrderSummaries(context: DatabaseContext, query?: string) {
const search = query?.trim();
const filters = search
? or(
sql`instr(${orders.orderNumber}, ${search}) > 0`,
sql`instr(${users.name}, ${search}) > 0`,
sql`instr(${users.email}, ${search}) > 0`
)
: undefined;
const rows = await context.db
.select({
order: orders,
customer: users,
itemCount: sql<number>`count(${orderItems.id})`
})
.from(orders)
.innerJoin(users, eq(orders.customerId, users.id))
.leftJoin(orderItems, eq(orderItems.orderId, orders.id))
.where(filters)
.groupBy(orders.id, users.id)
.orderBy(desc(orders.createdAt));
return rows.map(({ order, customer, itemCount }) => ({
id: order.id,
orderNumber: order.orderNumber,
status: order.status as OrderSummary["status"],
customer: {
id: customer.id,
name: customer.name,
email: customer.email
},
itemCount,
createdAt: order.createdAt
})) satisfies OrderSummary[];
}
export async function findOrderDetail(context: DatabaseContext, orderId: string) {
const [header] = await context.db
.select({ order: orders, customer: users, address: addresses })
.from(orders)
.innerJoin(users, eq(orders.customerId, users.id))
.innerJoin(addresses, eq(orders.shippingAddressId, addresses.id))
.where(eq(orders.id, orderId));
if (!header) {
return null;
}
const itemRows = await context.db
.select({ item: orderItems, product: products, inventory })
.from(orderItems)
.innerJoin(products, eq(orderItems.productId, products.id))
.leftJoin(inventory, eq(orderItems.productId, inventory.productId))
.where(eq(orderItems.orderId, orderId))
.orderBy(asc(orderItems.id));
return {
id: header.order.id,
orderNumber: header.order.orderNumber,
status: header.order.status as OrderDetail["status"],
createdAt: header.order.createdAt,
updatedAt: header.order.updatedAt,
customer: {
id: header.customer.id,
name: header.customer.name,
email: header.customer.email
},
shippingAddress: toAddress(header.address),
items: itemRows.map(toOrderItemDetail)
} satisfies OrderDetail;
}
export async function findOrderItemRecord(context: DatabaseContext, orderId: string, itemId: string) {
const [row] = await context.db
.select({ order: orders, item: orderItems, inventory })
.from(orderItems)
.innerJoin(orders, eq(orderItems.orderId, orders.id))
.leftJoin(inventory, eq(orderItems.productId, inventory.productId))
.where(and(eq(orderItems.id, itemId), eq(orderItems.orderId, orderId)));
return row ?? null;
}
export async function updateOrderItemQuantity(context: DatabaseContext, itemId: string, quantity: number) {
await context.db.update(orderItems).set({ quantity }).where(eq(orderItems.id, itemId));
}
export async function updateOrderTimestamp(context: DatabaseContext, orderId: string, updatedAt: string) {
await context.db.update(orders).set({ updatedAt }).where(eq(orders.id, orderId));
}
export async function updateOrderStatus(context: DatabaseContext, orderId: string, status: OrderDetail["status"], updatedAt: string) {
await context.db.update(orders).set({ status, updatedAt }).where(eq(orders.id, orderId));
}
export async function updateOrderAddress(context: DatabaseContext, orderId: string, address: Address) {
const [row] = await context.db.select({ shippingAddressId: orders.shippingAddressId }).from(orders).where(eq(orders.id, orderId));
if (!row) {
return false;
}
await context.db
.update(addresses)
.set({
fullName: address.fullName,
line1: address.line1,
line2: address.line2,
city: address.city,
state: address.state,
postalCode: address.postalCode,
country: address.country
})
.where(eq(addresses.id, row.shippingAddressId));
return true;
}
export async function appendAuditEvent(
context: DatabaseContext,
event: {
orderId: string;
actor: string;
type: string;
message: string;
createdAt: string;
}
) {
await context.db.insert(auditEvents).values({
id: crypto.randomUUID(),
orderId: event.orderId,
actor: event.actor,
type: event.type,
message: event.message,
createdAt: event.createdAt
});
}
async function findCustomerSummaryByOrderId(context: DatabaseContext, orderId: string) {
const [row] = await context.db
.select({ customer: users })
.from(orders)
.innerJoin(users, eq(orders.customerId, users.id))
.where(eq(orders.id, orderId));
return row?.customer ?? null;
}
export async function listOrderAuditEvents(context: DatabaseContext, orderId: string) {
const events = await context.db.select().from(auditEvents).where(eq(auditEvents.orderId, orderId)).orderBy(desc(auditEvents.createdAt));
return Promise.all(
events.map(async (event) => {
await findCustomerSummaryByOrderId(context, event.orderId);
return {
id: event.id,
orderId: event.orderId,
type: event.type,
message: event.message,
createdAt: event.createdAt,
actor: event.actor
} satisfies AuditEvent;
})
);
}
export async function listOrdersByIds(context: DatabaseContext, orderIds: string[]) {
return context.db.select().from(orders).where(inArray(orders.id, orderIds));
}
+160
View File
@@ -0,0 +1,160 @@
import type { Address, AuthUser } from "@orderops/shared";
import type { FastifyPluginAsync, FastifyRequest } from "fastify";
import { requireEmployeeUser } from "../auth/current-user";
import type { DatabaseContext } from "../db/database";
import { cancelOrder } from "./cancel-order";
import { getOrder } from "./get-order";
import { listAuditEvents } from "./list-audit-events";
import { listOrders } from "./list-orders";
import { changeOrderItemQuantity } from "./update-order-item";
import { changeShippingAddress } from "./update-shipping-address";
interface OrderRouteOptions {
db: DatabaseContext;
}
interface SearchQuery {
query?: string;
}
interface IdParams {
orderId: string;
}
interface OrderItemParams extends IdParams {
itemId: string;
}
interface QuantityBody {
quantity: number;
}
interface CancelBody {
reason?: string;
}
type AuthenticatedRequest = FastifyRequest & { currentUser: AuthUser };
export const registerOrderRoutes: FastifyPluginAsync<OrderRouteOptions> = async (app, options) => {
app.addHook("preHandler", async (request, reply) => {
const user = await requireEmployeeUser(options.db, request, reply);
if (!user) {
return reply;
}
(request as AuthenticatedRequest).currentUser = user;
});
app.get<{ Querystring: SearchQuery }>("/orders", {
schema: {
querystring: {
type: "object",
properties: {
query: { type: "string" }
}
}
}
}, async (request) => listOrders(options.db, request.query.query));
app.get<{ Params: IdParams }>("/orders/:orderId", async (request, reply) => {
const order = await getOrder(options.db, request.params.orderId);
if (!order) {
return reply.code(404).send({ message: "Order not found." });
}
return order;
});
app.get<{ Params: IdParams }>("/orders/:orderId/audit-events", async (request) => listAuditEvents(options.db, request.params.orderId));
app.post<{ Params: OrderItemParams; Body: QuantityBody }>("/orders/:orderId/items/:itemId/quantity", {
schema: {
body: {
type: "object",
required: ["quantity"],
properties: {
quantity: { type: "number" }
}
}
}
}, async (request, reply) => {
const currentUser = (request as AuthenticatedRequest).currentUser;
const result = await changeOrderItemQuantity(options.db, {
orderId: request.params.orderId,
itemId: request.params.itemId,
quantity: request.body.quantity,
actor: currentUser.username
});
if (result.statusCode !== 200) {
return reply.code(result.statusCode).send({ message: result.message });
}
return result.order;
});
app.post<{ Params: IdParams; Body: Address }>("/orders/:orderId/shipping-address", {
schema: {
body: {
type: "object",
required: ["fullName", "line1", "city", "state", "postalCode", "country"],
properties: {
fullName: { type: "string", minLength: 1 },
line1: { type: "string", minLength: 1 },
line2: { type: ["string", "null"] },
city: { type: "string", minLength: 1 },
state: { type: "string", minLength: 1 },
postalCode: { type: "string", minLength: 1 },
country: { type: "string", minLength: 1 }
}
}
}
}, async (request, reply) => {
const currentUser = (request as AuthenticatedRequest).currentUser;
const result = await changeShippingAddress(options.db, {
orderId: request.params.orderId,
address: {
fullName: request.body.fullName,
line1: request.body.line1,
line2: request.body.line2 ?? null,
city: request.body.city,
state: request.body.state,
postalCode: request.body.postalCode,
country: request.body.country
},
actor: currentUser.username
});
if (result.statusCode !== 200) {
return reply.code(result.statusCode).send({ message: result.message });
}
return result.order;
});
app.post<{ Params: IdParams; Body: CancelBody }>("/orders/:orderId/cancel", {
schema: {
body: {
type: "object",
properties: {
reason: { type: "string" }
}
}
}
}, async (request, reply) => {
const currentUser = (request as AuthenticatedRequest).currentUser;
const result = await cancelOrder(options.db, {
orderId: request.params.orderId,
reason: request.body.reason,
actor: currentUser.username
});
if (result.statusCode !== 200) {
return reply.code(result.statusCode).send({ message: result.message });
}
return result.order;
});
};
+13
View File
@@ -0,0 +1,13 @@
import type { OrderStatus } from "@orderops/shared/order-status";
export function canEditOrderItems(status: OrderStatus) {
return status === "pending" || status === "processing";
}
export function canEditShippingAddress(status: OrderStatus) {
return status === "pending" || status === "processing";
}
export function canCancelOrder(status: OrderStatus) {
return status === "pending" || status === "processing";
}
+103
View File
@@ -0,0 +1,103 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import type { AppWithContext } from "../app";
import { buildApp } from "../app";
import { createUser } from "../users/user-repository";
let app: AppWithContext;
let employeeCookie = "";
let customerCookie = "";
async function login(username: string) {
const response = await app.inject({ method: "POST", url: "/api/login", payload: { username } });
return response.headers["set-cookie"] as string;
}
beforeEach(async () => {
app = await buildApp(":memory:");
await createUser(app.dbContext, {
username: "dev-employee",
name: "Dev Employee",
email: "dev.employee@example.com",
role: "employee"
});
employeeCookie = await login("dev-employee");
customerCookie = await login("john-smith");
});
afterEach(async () => {
await app.close();
});
describe("order routes", () => {
test("rejects customer access", async () => {
const response = await app.inject({ method: "GET", url: "/api/orders", headers: { cookie: customerCookie } });
expect(response.statusCode).toBe(403);
});
test("lists seeded orders for employee", async () => {
const response = await app.inject({ method: "GET", url: "/api/orders", headers: { cookie: employeeCookie } });
expect(response.statusCode).toBe(200);
expect(response.json()).toHaveLength(7);
});
test("returns order detail", async () => {
const response = await app.inject({ method: "GET", url: "/api/orders/order_1", headers: { cookie: employeeCookie } });
const order = response.json();
expect(response.statusCode).toBe(200);
expect(order.orderNumber).toBe("ORD-1001");
expect(order.items).toHaveLength(2);
});
test("updates item quantity with inventory check", async () => {
const response = await app.inject({
method: "POST",
url: "/api/orders/order_1/items/item_1/quantity",
headers: { cookie: employeeCookie },
payload: { quantity: 5 }
});
const order = response.json();
expect(response.statusCode).toBe(200);
expect(order.items.find((item: { id: string }) => item.id === "item_1")?.quantity).toBe(5);
});
test("updates shipping address", async () => {
const response = await app.inject({
method: "POST",
url: "/api/orders/order_1/shipping-address",
headers: { cookie: employeeCookie },
payload: {
fullName: "John Smith",
line1: "50 New Street",
line2: null,
city: "Oakland",
state: "CA",
postalCode: "94607",
country: "US"
}
});
const order = response.json();
expect(response.statusCode).toBe(200);
expect(order.shippingAddress.city).toBe("Oakland");
});
test("cancels eligible order", async () => {
const response = await app.inject({
method: "POST",
url: "/api/orders/order_2/cancel",
headers: { cookie: employeeCookie },
payload: { reason: "Customer request" }
});
expect(response.statusCode).toBe(200);
expect(response.json().status).toBe("cancelled");
});
});
+49
View File
@@ -0,0 +1,49 @@
import type { DatabaseContext } from "../db/database";
import { canEditOrderItems } from "./order-status";
import { appendAuditEvent, findOrderDetail, findOrderItemRecord, updateOrderItemQuantity, updateOrderTimestamp } from "./order-repository";
export async function changeOrderItemQuantity(
context: DatabaseContext,
input: {
orderId: string;
itemId: string;
quantity: number;
actor: string;
}
) {
const row = await findOrderItemRecord(context, input.orderId, input.itemId);
if (!row) {
return { statusCode: 404, message: "Order item not found." } as const;
}
if (!canEditOrderItems(row.order.status as never)) {
return { statusCode: 400, message: "Order items cannot be changed for this order status." } as const;
}
if (input.quantity < 1 || input.quantity > 10) {
return { statusCode: 400, message: "Quantity must be between 1 and 10." } as const;
}
const available = (row.inventory?.availableQuantity ?? 0) + row.item.quantity;
if (input.quantity > available) {
return { statusCode: 400, message: "Requested quantity exceeds available inventory." } as const;
}
const updatedAt = new Date().toISOString();
await updateOrderItemQuantity(context, input.itemId, input.quantity);
await updateOrderTimestamp(context, input.orderId, updatedAt);
await appendAuditEvent(context, {
orderId: input.orderId,
actor: input.actor,
type: "order.item.quantity_changed",
message: `Updated item ${input.itemId} quantity to ${input.quantity}.`,
createdAt: updatedAt
});
return {
statusCode: 200,
order: await findOrderDetail(context, input.orderId)
} as const;
}
@@ -0,0 +1,44 @@
import type { Address } from "@orderops/shared/address";
import type { DatabaseContext } from "../db/database";
import { canEditShippingAddress } from "./order-status";
import { appendAuditEvent, findOrderDetail, updateOrderAddress, updateOrderTimestamp } from "./order-repository";
export async function changeShippingAddress(
context: DatabaseContext,
input: {
orderId: string;
address: Address;
actor: string;
}
) {
const order = await findOrderDetail(context, input.orderId);
if (!order) {
return { statusCode: 404, message: "Order not found." } as const;
}
if (!canEditShippingAddress(order.status)) {
return { statusCode: 400, message: "Shipping address cannot be changed for this order status." } as const;
}
const updatedAt = new Date().toISOString();
const updated = await updateOrderAddress(context, input.orderId, input.address);
if (!updated) {
return { statusCode: 404, message: "Order not found." } as const;
}
await updateOrderTimestamp(context, input.orderId, updatedAt);
await appendAuditEvent(context, {
orderId: input.orderId,
actor: input.actor,
type: "order.shipping_address_changed",
message: "Updated shipping address.",
createdAt: updatedAt
});
return {
statusCode: 200,
order: await findOrderDetail(context, input.orderId)
} as const;
}
+8
View File
@@ -0,0 +1,8 @@
import { buildApp } from "./app";
const port = Number(Bun.env.PORT ?? 3001);
const host = Bun.env.HOST ?? "localhost";
const app = await buildApp();
await app.listen({ port, host });
+16
View File
@@ -0,0 +1,16 @@
const prefixes = ["amber", "brisk", "cinder", "dune", "ember", "harbor", "lunar", "maple", "silver", "willow"];
const suffixes = ["brook", "field", "glow", "harbor", "meadow", "otter", "pine", "ridge", "stone", "trail"];
export function generateUsername() {
const prefix = prefixes[Math.floor(Math.random() * prefixes.length)];
const suffix = suffixes[Math.floor(Math.random() * suffixes.length)];
return `${prefix}-${suffix}`;
}
export function toDisplayName(username: string) {
return username
.split("-")
.map((part) => `${part.slice(0, 1).toUpperCase()}${part.slice(1)}`)
.join(" ");
}
+43
View File
@@ -0,0 +1,43 @@
import { createInterface } from "node:readline/promises";
import { stdin, stdout } from "node:process";
import type { UserRole } from "@orderops/shared";
import { createDatabaseContext } from "../db/database";
import { seedUser } from "./seed-user";
function parseRole(value: string | undefined): UserRole | null {
if (value === "customer" || value === "employee") {
return value;
}
return null;
}
async function promptForRole(): Promise<UserRole> {
const readline = createInterface({ input: stdin, output: stdout });
try {
for (;;) {
const answer = (await readline.question("Role (customer/employee): ")).trim().toLowerCase();
const role = parseRole(answer);
if (role) {
return role;
}
}
} finally {
readline.close();
}
}
const role = parseRole(Bun.argv[2]) ?? (await promptForRole());
const context = await createDatabaseContext();
try {
const user = await seedUser(context, role);
console.log(`Created ${user.role} user`);
console.log(`username: ${user.username}`);
console.log(`name: ${user.name}`);
} finally {
context.sqlite.close();
}
+25
View File
@@ -0,0 +1,25 @@
import type { AuthUser, UserRole } from "@orderops/shared";
import { findUserByUsername } from "./user-repository";
import { generateUsername, toDisplayName } from "./generate-username";
import { createUser } from "./user-repository";
import type { DatabaseContext } from "../db/database";
export async function seedUser(context: DatabaseContext, role: UserRole): Promise<AuthUser> {
for (let attempt = 0; attempt < 25; attempt += 1) {
const username = generateUsername();
const existingUser = await findUserByUsername(context, username);
if (existingUser) {
continue;
}
return createUser(context, {
username,
name: toDisplayName(username),
email: `${username}@example.com`,
role
});
}
throw new Error("Could not generate unique username.");
}
+40
View File
@@ -0,0 +1,40 @@
import type { AuthUser, UserRole } from "@orderops/shared";
import { eq } from "drizzle-orm";
import type { DatabaseContext } from "../db/database";
import { users } from "../db/schema";
export interface CreateUserInput {
username: string;
name: string;
email: string;
role: UserRole;
}
function toAuthUser(user: typeof users.$inferSelect): AuthUser {
return {
id: user.id,
username: user.username,
name: user.name,
role: user.role as UserRole
};
}
export async function findUserByUsername(context: DatabaseContext, username: string) {
const [user] = await context.db.select().from(users).where(eq(users.username, username));
return user ? toAuthUser(user) : null;
}
export async function createUser(context: DatabaseContext, input: CreateUserInput) {
const user = {
id: crypto.randomUUID(),
username: input.username,
name: input.name,
email: input.email,
role: input.role,
createdAt: new Date().toISOString()
};
await context.db.insert(users).values(user);
return toAuthUser(user);
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"types": ["bun"],
"rootDir": "src"
},
"include": ["src/**/*.ts"]
}