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 { const client = await this.pool.connect(); try { await client.query('BEGIN'); const orderResult = await client.query( `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( `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 { const result = await this.pool.query( '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( '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( `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( '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 { const orderResult = await this.pool.query( `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( 'SELECT * FROM orders_items WHERE order_id = $1 ORDER BY created_at, id', [id], ); const [shippingRow] = await this.pool.query( `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( `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( `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 { const orderResult = await this.pool.query( `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( '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 { const result = await this.pool.query( `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 { const client = await this.pool.connect(); try { await client.query('BEGIN'); const orderResult = await client.query( `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( `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, }; }