349 lines
12 KiB
TypeScript
349 lines
12 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;
|
|
customer_name: string | null;
|
|
customer_phone: string | null;
|
|
phone: string | null;
|
|
created_at: Date;
|
|
updated_at: Date;
|
|
}
|
|
|
|
interface AddressRow {
|
|
recipient_name: string;
|
|
street: string;
|
|
city: string;
|
|
postal_code: string;
|
|
country: string;
|
|
}
|
|
|
|
interface PaymentRow {
|
|
provider: string;
|
|
status: string;
|
|
amount_cents: number;
|
|
provider_payment_id: string | null;
|
|
created_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,
|
|
COALESCE(o.customer_name, p.display_name) AS customer_name,
|
|
COALESCE(o.customer_phone, p.phone, u.phone) AS customer_phone,
|
|
COALESCE(u.phone, p.phone) AS phone
|
|
FROM orders_orders o
|
|
LEFT JOIN identity_users u ON u.id = o.user_id
|
|
LEFT JOIN users_profiles p ON p.user_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],
|
|
);
|
|
const [shippingRow] = await this.pool.query<AddressRow>(
|
|
`SELECT recipient_name, street, city, postal_code, country
|
|
FROM users_addresses WHERE user_id = $1 AND type = 'shipping' LIMIT 1`,
|
|
[order.user_id],
|
|
).then((r) => r.rows);
|
|
const [billingRow] = await this.pool.query<AddressRow>(
|
|
`SELECT recipient_name, street, city, postal_code, country
|
|
FROM users_addresses WHERE user_id = $1 AND type = 'billing' LIMIT 1`,
|
|
[order.user_id],
|
|
).then((r) => r.rows);
|
|
const [paymentRow] = await this.pool.query<PaymentRow>(
|
|
`SELECT provider, status, amount_cents, provider_payment_id, created_at
|
|
FROM payments_transactions
|
|
WHERE order_id = $1 AND status = 'succeeded'
|
|
ORDER BY created_at DESC LIMIT 1`,
|
|
[id],
|
|
).then((r) => r.rows);
|
|
return toOrderView(order, items.rows.map(toItem), shippingRow, billingRow, paymentRow);
|
|
}
|
|
|
|
async findByIdAndUserId(id: string, userId: string): Promise<OrderView | undefined> {
|
|
const orderResult = await this.pool.query<OrderRow>(
|
|
`SELECT o.*, u.email,
|
|
COALESCE(o.customer_name, p.display_name) AS customer_name,
|
|
COALESCE(o.customer_phone, p.phone, u.phone) AS customer_phone,
|
|
COALESCE(u.phone, p.phone) AS phone
|
|
FROM orders_orders o
|
|
LEFT JOIN identity_users u ON u.id = o.user_id
|
|
LEFT JOIN users_profiles p ON p.user_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,
|
|
customerName: row.customer_name ?? null,
|
|
customerPhone: row.customer_phone ?? null,
|
|
createdAt: row.created_at,
|
|
updatedAt: row.updated_at,
|
|
};
|
|
}
|
|
|
|
function toOrderView(
|
|
row: OrderRow,
|
|
items: OrderItem[],
|
|
shipping?: AddressRow | null,
|
|
billing?: AddressRow | null,
|
|
payment?: PaymentRow | null,
|
|
): OrderView {
|
|
const last4 = payment?.provider_payment_id
|
|
? payment.provider_payment_id.replace(/[^0-9]/g, '').slice(-4)
|
|
: null;
|
|
return {
|
|
...toOrder(row),
|
|
email: row.email,
|
|
phone: row.phone ?? null,
|
|
items,
|
|
shippingAddress: shipping ? { recipientName: shipping.recipient_name, street: shipping.street, city: shipping.city, postalCode: shipping.postal_code, country: shipping.country } : null,
|
|
billingAddress: billing ? { recipientName: billing.recipient_name, street: billing.street, city: billing.city, postalCode: billing.postal_code, country: billing.country } : null,
|
|
payment: payment ? { provider: payment.provider, status: payment.status, amountCents: payment.amount_cents, last4, createdAt: payment.created_at } : null,
|
|
};
|
|
}
|
|
|
|
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,
|
|
};
|
|
}
|