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
+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;
}