Files
mercadodevida/project/src/modules/orders/infrastructure/pg-order-repository.ts
2026-08-22 07:41:08 +02:00

284 lines
8.9 KiB
TypeScript

import type pg from 'pg';
import type { OrderRepository } from '../domain/ports.js';
import type { Order, OrderItem, OrderItemInput, OrderState, OrderView } from '../domain/order.js';
interface OrderRow {
id: string;
user_id: string;
email: string | null;
idempotency_key: string | null;
state: OrderState;
currency: 'EUR';
subtotal_cents: number;
discount_cents: number;
tax_cents: number;
total_cents: number;
tracking_number: string | null;
courier: string | null;
created_at: Date;
updated_at: Date;
}
interface ItemRow {
id: string;
order_id: string;
product_id: string;
variant_id: string;
sku: string;
ean: string | null;
name: string;
unit_price_cents: number;
discount_cents: number;
tax_cents: number;
quantity: number;
created_at: Date;
}
interface CreateInput {
userId: string;
idempotencyKey: string | null;
items: OrderItemInput[];
totals: { subtotalCents: number; discountCents: number; taxCents: number; totalCents: number };
}
export class PgOrderRepository implements OrderRepository {
constructor(private readonly pool: pg.Pool) {}
async create(input: CreateInput): Promise<OrderView> {
const client = await this.pool.connect();
try {
await client.query('BEGIN');
const orderResult = await client.query<OrderRow>(
`INSERT INTO orders_orders (user_id, idempotency_key, subtotal_cents, discount_cents, tax_cents, total_cents)
VALUES ($1, $2, $3, $4, $5, $6) RETURNING *`,
[
input.userId,
input.idempotencyKey,
input.totals.subtotalCents,
input.totals.discountCents,
input.totals.taxCents,
input.totals.totalCents,
],
);
const order = orderResult.rows[0];
if (!order) throw new Error('orders_orders INSERT returned no row');
for (const item of input.items) {
await client.query<ItemRow>(
`INSERT INTO orders_items (order_id, product_id, variant_id, sku, ean, name, unit_price_cents, discount_cents, tax_cents, quantity)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING *`,
[
order.id,
item.productId,
item.variantId,
item.sku,
item.ean,
item.name,
item.unitPriceCents,
item.discountCents,
item.taxCents,
item.quantity,
],
);
}
await client.query('COMMIT');
const loaded = await this.findById(order.id);
if (!loaded) throw new Error('orders_orders load after create returned no row');
return loaded;
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
async findAll(): Promise<OrderView[]> {
const result = await this.pool.query<OrderRow>(
'SELECT o.*, u.email FROM orders_orders o LEFT JOIN identity_users u ON u.id = o.user_id ORDER BY o.created_at DESC',
);
return Promise.all(
result.rows.map(async (order) => {
const items = await this.pool.query<ItemRow>(
'SELECT * FROM orders_items WHERE order_id = $1 ORDER BY id',
[order.id],
);
return toOrderView(order, items.rows.map(toItem));
}),
);
}
async search(filters: {
state?: string;
q?: string;
limit: number;
offset: number;
}): Promise<{ items: OrderView[]; total: number }> {
const where: string[] = [];
const values: unknown[] = [];
if (filters.state) {
values.push(filters.state);
where.push(`o.state = $${values.length}`);
}
if (filters.q?.trim()) {
values.push(`%${filters.q.trim()}%`);
where.push(`(o.id::text ILIKE $${values.length} OR u.email ILIKE $${values.length})`);
}
const whereSql = where.length ? `WHERE ${where.join(' AND ')}` : '';
const countResult = await this.pool.query<{ count: string }>(
`SELECT COUNT(*)::text AS count
FROM orders_orders o
LEFT JOIN identity_users u ON u.id = o.user_id
${whereSql}`,
values,
);
const total = Number(countResult.rows[0]?.count ?? '0');
values.push(filters.limit, filters.offset);
const result = await this.pool.query<OrderRow>(
`SELECT o.*, u.email FROM orders_orders o
LEFT JOIN identity_users u ON u.id = o.user_id
${whereSql}
ORDER BY o.created_at DESC
LIMIT $${values.length - 1} OFFSET $${values.length}`,
values,
);
const items = await Promise.all(
result.rows.map(async (order) => {
const itemResult = await this.pool.query<ItemRow>(
'SELECT * FROM orders_items WHERE order_id = $1 ORDER BY id',
[order.id],
);
return toOrderView(order, itemResult.rows.map(toItem));
}),
);
return { items, total };
}
async findById(id: string): Promise<OrderView | undefined> {
const orderResult = await this.pool.query<OrderRow>(
'SELECT o.*, u.email FROM orders_orders o LEFT JOIN identity_users u ON u.id = o.user_id WHERE o.id = $1',
[id],
);
const order = orderResult.rows[0];
if (!order) return undefined;
const items = await this.pool.query<ItemRow>(
'SELECT * FROM orders_items WHERE order_id = $1 ORDER BY created_at, id',
[id],
);
return toOrderView(order, items.rows.map(toItem));
}
async findByIdAndUserId(id: string, userId: string): Promise<OrderView | undefined> {
const orderResult = await this.pool.query<OrderRow>(
'SELECT o.*, u.email FROM orders_orders o LEFT JOIN identity_users u ON u.id = o.user_id WHERE o.id = $1 AND o.user_id = $2',
[id, userId],
);
const order = orderResult.rows[0];
if (!order) return undefined;
const items = await this.pool.query<ItemRow>(
'SELECT * FROM orders_items WHERE order_id = $1 ORDER BY created_at, id',
[id],
);
return toOrderView(order, items.rows.map(toItem));
}
async updateState(id: string, state: OrderState, trackingNumber?: string, courier?: string): Promise<OrderView | undefined> {
const result = await this.pool.query<OrderRow>(
`UPDATE orders_orders SET state = $2, tracking_number = COALESCE($3, tracking_number), courier = COALESCE($4, courier), updated_at = now()
WHERE id = $1 RETURNING *`,
[id, state, trackingNumber ?? null, courier ?? null],
);
const order = result.rows[0];
if (!order) return undefined;
return this.findById(order.id);
}
async replaceItems(
id: string,
items: OrderItemInput[],
totals: { subtotalCents: number; discountCents: number; taxCents: number; totalCents: number },
): Promise<OrderView | undefined> {
const client = await this.pool.connect();
try {
await client.query('BEGIN');
const orderResult = await client.query<OrderRow>(
`UPDATE orders_orders
SET subtotal_cents = $2, discount_cents = $3, tax_cents = $4, total_cents = $5, updated_at = now()
WHERE id = $1 RETURNING *`,
[id, totals.subtotalCents, totals.discountCents, totals.taxCents, totals.totalCents],
);
if (!orderResult.rows[0]) {
await client.query('ROLLBACK');
return undefined;
}
await client.query('DELETE FROM orders_items WHERE order_id = $1', [id]);
for (const item of items) {
await client.query<ItemRow>(
`INSERT INTO orders_items (order_id, product_id, variant_id, sku, ean, name, unit_price_cents, discount_cents, tax_cents, quantity)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
[
id,
item.productId,
item.variantId,
item.sku,
item.ean,
item.name,
item.unitPriceCents,
item.discountCents,
item.taxCents,
item.quantity,
],
);
}
await client.query('COMMIT');
return this.findById(id);
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
}
function toOrder(row: OrderRow): Order {
return {
id: row.id,
userId: row.user_id,
idempotencyKey: row.idempotency_key,
state: row.state,
currency: row.currency,
subtotalCents: row.subtotal_cents,
discountCents: row.discount_cents,
taxCents: row.tax_cents,
totalCents: row.total_cents,
trackingNumber: row.tracking_number,
courier: row.courier,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
function toOrderView(row: OrderRow, items: OrderItem[]): OrderView {
return {
...toOrder(row),
email: row.email,
items,
};
}
function toItem(row: ItemRow): OrderItem {
return {
id: row.id,
orderId: row.order_id,
productId: row.product_id,
variantId: row.variant_id,
sku: row.sku,
ean: row.ean,
name: row.name,
unitPriceCents: row.unit_price_cents,
discountCents: row.discount_cents,
taxCents: row.tax_cents,
quantity: row.quantity,
createdAt: row.created_at,
};
}