@venizia/ignis-docs 0.0.2 → 0.0.4-0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/package.json +4 -2
- package/wiki/best-practices/api-usage-examples.md +591 -0
- package/wiki/best-practices/architectural-patterns.md +415 -0
- package/wiki/best-practices/architecture-decisions.md +488 -0
- package/wiki/{get-started/best-practices → best-practices}/code-style-standards.md +647 -182
- package/wiki/{get-started/best-practices → best-practices}/common-pitfalls.md +109 -4
- package/wiki/{get-started/best-practices → best-practices}/contribution-workflow.md +34 -7
- package/wiki/best-practices/data-modeling.md +376 -0
- package/wiki/best-practices/deployment-strategies.md +698 -0
- package/wiki/best-practices/index.md +27 -0
- package/wiki/best-practices/performance-optimization.md +196 -0
- package/wiki/best-practices/security-guidelines.md +218 -0
- package/wiki/{get-started/best-practices → best-practices}/troubleshooting-tips.md +97 -1
- package/wiki/changelogs/2025-12-16-initial-architecture.md +1 -1
- package/wiki/changelogs/2025-12-16-model-repo-datasource-refactor.md +1 -1
- package/wiki/changelogs/2025-12-17-refactor.md +1 -1
- package/wiki/changelogs/2025-12-18-performance-optimizations.md +5 -5
- package/wiki/changelogs/2025-12-18-repository-validation-security.md +13 -7
- package/wiki/changelogs/2025-12-26-nested-relations-and-generics.md +86 -0
- package/wiki/changelogs/2025-12-26-transaction-support.md +57 -0
- package/wiki/changelogs/2025-12-29-dynamic-binding-registration.md +104 -0
- package/wiki/changelogs/2025-12-29-snowflake-uid-helper.md +100 -0
- package/wiki/changelogs/2025-12-30-repository-enhancements.md +214 -0
- package/wiki/changelogs/2025-12-31-json-path-filtering-array-operators.md +214 -0
- package/wiki/changelogs/2025-12-31-string-id-custom-generator.md +137 -0
- package/wiki/changelogs/2026-01-02-default-filter-and-repository-mixins.md +418 -0
- package/wiki/changelogs/index.md +8 -1
- package/wiki/changelogs/planned-schema-migrator.md +2 -10
- package/wiki/{get-started/core-concepts → guides/core-concepts/application}/bootstrapping.md +18 -5
- package/wiki/{get-started/core-concepts/application.md → guides/core-concepts/application/index.md} +47 -104
- package/wiki/guides/core-concepts/components-guide.md +509 -0
- package/wiki/guides/core-concepts/components.md +122 -0
- package/wiki/{get-started → guides}/core-concepts/controllers.md +30 -13
- package/wiki/{get-started → guides}/core-concepts/dependency-injection.md +97 -0
- package/wiki/guides/core-concepts/persistent/datasources.md +179 -0
- package/wiki/guides/core-concepts/persistent/index.md +119 -0
- package/wiki/guides/core-concepts/persistent/models.md +241 -0
- package/wiki/guides/core-concepts/persistent/repositories.md +219 -0
- package/wiki/guides/core-concepts/persistent/transactions.md +170 -0
- package/wiki/{get-started → guides}/core-concepts/services.md +26 -3
- package/wiki/{get-started → guides/get-started}/5-minute-quickstart.md +59 -14
- package/wiki/guides/get-started/philosophy.md +682 -0
- package/wiki/guides/get-started/setup.md +157 -0
- package/wiki/guides/index.md +89 -0
- package/wiki/guides/reference/glossary.md +243 -0
- package/wiki/{get-started → guides/reference}/mcp-docs-server.md +0 -10
- package/wiki/{get-started → guides/tutorials}/building-a-crud-api.md +134 -132
- package/wiki/{get-started/quickstart.md → guides/tutorials/complete-installation.md} +107 -71
- package/wiki/guides/tutorials/ecommerce-api.md +1399 -0
- package/wiki/guides/tutorials/realtime-chat.md +1261 -0
- package/wiki/guides/tutorials/testing.md +723 -0
- package/wiki/index.md +176 -37
- package/wiki/references/base/application.md +27 -0
- package/wiki/references/base/bootstrapping.md +30 -26
- package/wiki/references/base/components.md +532 -31
- package/wiki/references/base/controllers.md +136 -38
- package/wiki/references/base/datasources.md +108 -5
- package/wiki/references/base/dependency-injection.md +39 -3
- package/wiki/references/base/filter-system/application-usage.md +224 -0
- package/wiki/references/base/filter-system/array-operators.md +132 -0
- package/wiki/references/base/filter-system/comparison-operators.md +109 -0
- package/wiki/references/base/filter-system/default-filter.md +428 -0
- package/wiki/references/base/filter-system/fields-order-pagination.md +155 -0
- package/wiki/references/base/filter-system/index.md +127 -0
- package/wiki/references/base/filter-system/json-filtering.md +197 -0
- package/wiki/references/base/filter-system/list-operators.md +71 -0
- package/wiki/references/base/filter-system/logical-operators.md +156 -0
- package/wiki/references/base/filter-system/null-operators.md +58 -0
- package/wiki/references/base/filter-system/pattern-matching.md +108 -0
- package/wiki/references/base/filter-system/quick-reference.md +431 -0
- package/wiki/references/base/filter-system/range-operators.md +63 -0
- package/wiki/references/base/filter-system/tips.md +190 -0
- package/wiki/references/base/filter-system/use-cases.md +452 -0
- package/wiki/references/base/index.md +90 -0
- package/wiki/references/base/middlewares.md +602 -0
- package/wiki/references/base/models.md +215 -23
- package/wiki/references/base/providers.md +732 -0
- package/wiki/references/base/repositories/advanced.md +555 -0
- package/wiki/references/base/repositories/index.md +228 -0
- package/wiki/references/base/repositories/mixins.md +331 -0
- package/wiki/references/base/repositories/relations.md +486 -0
- package/wiki/references/base/repositories.md +40 -549
- package/wiki/references/base/services.md +28 -4
- package/wiki/references/components/authentication.md +22 -2
- package/wiki/references/components/health-check.md +12 -0
- package/wiki/references/components/index.md +23 -0
- package/wiki/references/components/mail.md +687 -0
- package/wiki/references/components/request-tracker.md +16 -0
- package/wiki/references/components/socket-io.md +18 -0
- package/wiki/references/components/static-asset.md +14 -26
- package/wiki/references/components/swagger.md +17 -0
- package/wiki/references/configuration/environment-variables.md +427 -0
- package/wiki/references/configuration/index.md +73 -0
- package/wiki/references/helpers/cron.md +14 -0
- package/wiki/references/helpers/crypto.md +15 -0
- package/wiki/references/helpers/env.md +16 -0
- package/wiki/references/helpers/error.md +17 -0
- package/wiki/references/helpers/index.md +15 -0
- package/wiki/references/helpers/inversion.md +24 -4
- package/wiki/references/helpers/logger.md +19 -0
- package/wiki/references/helpers/network.md +11 -0
- package/wiki/references/helpers/queue.md +19 -0
- package/wiki/references/helpers/redis.md +21 -0
- package/wiki/references/helpers/socket-io.md +24 -5
- package/wiki/references/helpers/storage.md +18 -10
- package/wiki/references/helpers/testing.md +18 -0
- package/wiki/references/helpers/types.md +167 -0
- package/wiki/references/helpers/uid.md +167 -0
- package/wiki/references/helpers/worker-thread.md +16 -0
- package/wiki/references/index.md +177 -0
- package/wiki/references/quick-reference.md +634 -0
- package/wiki/references/src-details/boot.md +3 -3
- package/wiki/references/src-details/dev-configs.md +0 -4
- package/wiki/references/src-details/docs.md +2 -2
- package/wiki/references/src-details/index.md +86 -0
- package/wiki/references/src-details/inversion.md +1 -6
- package/wiki/references/src-details/mcp-server.md +3 -15
- package/wiki/references/utilities/index.md +86 -10
- package/wiki/references/utilities/jsx.md +577 -0
- package/wiki/references/utilities/request.md +0 -2
- package/wiki/references/utilities/statuses.md +740 -0
- package/wiki/changelogs/planned-transaction-support.md +0 -216
- package/wiki/get-started/best-practices/api-usage-examples.md +0 -266
- package/wiki/get-started/best-practices/architectural-patterns.md +0 -170
- package/wiki/get-started/best-practices/data-modeling.md +0 -177
- package/wiki/get-started/best-practices/deployment-strategies.md +0 -121
- package/wiki/get-started/best-practices/performance-optimization.md +0 -88
- package/wiki/get-started/best-practices/security-guidelines.md +0 -99
- package/wiki/get-started/core-concepts/components.md +0 -98
- package/wiki/get-started/core-concepts/persistent.md +0 -543
- package/wiki/get-started/index.md +0 -65
- package/wiki/get-started/philosophy.md +0 -296
- package/wiki/get-started/prerequisites.md +0 -113
|
@@ -0,0 +1,1399 @@
|
|
|
1
|
+
# Building an E-commerce API
|
|
2
|
+
|
|
3
|
+
This tutorial guides you through building a complete e-commerce backend with products, orders, cart, and payments. You'll learn advanced patterns for real-world applications.
|
|
4
|
+
|
|
5
|
+
**⏱️ Time to Complete:** ~2 hours
|
|
6
|
+
|
|
7
|
+
## What You'll Build
|
|
8
|
+
|
|
9
|
+
- Product catalog with categories
|
|
10
|
+
- Shopping cart management
|
|
11
|
+
- Order processing
|
|
12
|
+
- Payment integration (Stripe)
|
|
13
|
+
- Inventory management
|
|
14
|
+
|
|
15
|
+
## Prerequisites
|
|
16
|
+
|
|
17
|
+
- Completed [Building a CRUD API](./building-a-crud-api.md)
|
|
18
|
+
- PostgreSQL database running
|
|
19
|
+
- Basic understanding of [Dependency Injection](../core-concepts/dependency-injection.md)
|
|
20
|
+
|
|
21
|
+
## 1. Project Setup
|
|
22
|
+
|
|
23
|
+
### Initialize the Project
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
mkdir ecommerce-api
|
|
27
|
+
cd ecommerce-api
|
|
28
|
+
bun init -y
|
|
29
|
+
|
|
30
|
+
# Install dependencies
|
|
31
|
+
bun add hono @hono/zod-openapi @venizia/ignis dotenv-flow
|
|
32
|
+
bun add drizzle-orm drizzle-zod pg stripe
|
|
33
|
+
bun add -d typescript @types/bun @venizia/dev-configs drizzle-kit @types/pg
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
### Project Structure
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
ecommerce-api/
|
|
40
|
+
├── src/
|
|
41
|
+
│ ├── index.ts
|
|
42
|
+
│ ├── application.ts
|
|
43
|
+
│ ├── models/
|
|
44
|
+
│ │ ├── product.model.ts
|
|
45
|
+
│ │ ├── category.model.ts
|
|
46
|
+
│ │ ├── cart.model.ts
|
|
47
|
+
│ │ ├── order.model.ts
|
|
48
|
+
│ │ └── index.ts
|
|
49
|
+
│ ├── repositories/
|
|
50
|
+
│ │ ├── product.repository.ts
|
|
51
|
+
│ │ ├── category.repository.ts
|
|
52
|
+
│ │ ├── cart.repository.ts
|
|
53
|
+
│ │ └── order.repository.ts
|
|
54
|
+
│ ├── services/
|
|
55
|
+
│ │ ├── product.service.ts
|
|
56
|
+
│ │ ├── cart.service.ts
|
|
57
|
+
│ │ ├── order.service.ts
|
|
58
|
+
│ │ └── payment.service.ts
|
|
59
|
+
│ ├── controllers/
|
|
60
|
+
│ │ ├── product.controller.ts
|
|
61
|
+
│ │ ├── cart.controller.ts
|
|
62
|
+
│ │ └── order.controller.ts
|
|
63
|
+
│ └── datasources/
|
|
64
|
+
│ └── postgres.datasource.ts
|
|
65
|
+
└── package.json
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## 2. Database Models
|
|
69
|
+
|
|
70
|
+
Models in IGNIS combine Drizzle ORM schemas with Entity classes.
|
|
71
|
+
|
|
72
|
+
### Category Model
|
|
73
|
+
|
|
74
|
+
```typescript
|
|
75
|
+
// src/models/category.model.ts
|
|
76
|
+
import {
|
|
77
|
+
BaseEntity,
|
|
78
|
+
createRelations,
|
|
79
|
+
generateIdColumnDefs,
|
|
80
|
+
generateTzColumnDefs,
|
|
81
|
+
model,
|
|
82
|
+
TTableObject,
|
|
83
|
+
} from '@venizia/ignis';
|
|
84
|
+
import { pgTable, text, varchar } from 'drizzle-orm/pg-core';
|
|
85
|
+
|
|
86
|
+
export const categoryTable = pgTable('Category', {
|
|
87
|
+
...generateIdColumnDefs({ id: { dataType: 'string' } }),
|
|
88
|
+
...generateTzColumnDefs(),
|
|
89
|
+
name: varchar('name', { length: 100 }).notNull(),
|
|
90
|
+
slug: varchar('slug', { length: 100 }).unique().notNull(),
|
|
91
|
+
description: text('description'),
|
|
92
|
+
parentId: text('parent_id'),
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
export const categoryRelations = createRelations({
|
|
96
|
+
source: categoryTable,
|
|
97
|
+
relations: [
|
|
98
|
+
{ type: 'one', name: 'parent', target: () => categoryTable, fields: ['parentId'], references: ['id'] },
|
|
99
|
+
{ type: 'many', name: 'children', target: () => categoryTable, fields: ['id'], references: ['parentId'] },
|
|
100
|
+
],
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
export type TCategorySchema = typeof categoryTable;
|
|
104
|
+
export type TCategory = TTableObject<TCategorySchema>;
|
|
105
|
+
|
|
106
|
+
@model({ type: 'entity' })
|
|
107
|
+
export class Category extends BaseEntity<typeof Category.schema> {
|
|
108
|
+
static override schema = categoryTable;
|
|
109
|
+
static override relations = () => categoryRelations.definitions;
|
|
110
|
+
static override TABLE_NAME = 'Category';
|
|
111
|
+
}
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
### Product Model
|
|
115
|
+
|
|
116
|
+
```typescript
|
|
117
|
+
// src/models/product.model.ts
|
|
118
|
+
import {
|
|
119
|
+
BaseEntity,
|
|
120
|
+
createRelations,
|
|
121
|
+
generateIdColumnDefs,
|
|
122
|
+
generateTzColumnDefs,
|
|
123
|
+
model,
|
|
124
|
+
TTableObject,
|
|
125
|
+
} from '@venizia/ignis';
|
|
126
|
+
import { pgTable, text, varchar, decimal, integer, boolean } from 'drizzle-orm/pg-core';
|
|
127
|
+
import { categoryTable, Category } from './category.model';
|
|
128
|
+
|
|
129
|
+
export const productTable = pgTable('Product', {
|
|
130
|
+
...generateIdColumnDefs({ id: { dataType: 'string' } }),
|
|
131
|
+
...generateTzColumnDefs(),
|
|
132
|
+
name: varchar('name', { length: 255 }).notNull(),
|
|
133
|
+
description: text('description'),
|
|
134
|
+
price: decimal('price', { precision: 10, scale: 2 }).notNull(),
|
|
135
|
+
compareAtPrice: decimal('compare_at_price', { precision: 10, scale: 2 }),
|
|
136
|
+
sku: varchar('sku', { length: 100 }).unique(),
|
|
137
|
+
stock: integer('stock').default(0).notNull(),
|
|
138
|
+
isActive: boolean('is_active').default(true).notNull(),
|
|
139
|
+
categoryId: text('category_id'),
|
|
140
|
+
imageUrl: text('image_url'),
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
export const productRelations = createRelations({
|
|
144
|
+
source: productTable,
|
|
145
|
+
relations: [
|
|
146
|
+
{ type: 'one', name: 'category', target: () => categoryTable, fields: ['categoryId'], references: ['id'] },
|
|
147
|
+
],
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
export type TProductSchema = typeof productTable;
|
|
151
|
+
export type TProduct = TTableObject<TProductSchema>;
|
|
152
|
+
|
|
153
|
+
@model({ type: 'entity' })
|
|
154
|
+
export class Product extends BaseEntity<typeof Product.schema> {
|
|
155
|
+
static override schema = productTable;
|
|
156
|
+
static override relations = () => productRelations.definitions;
|
|
157
|
+
static override TABLE_NAME = 'Product';
|
|
158
|
+
}
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
### Cart Model
|
|
162
|
+
|
|
163
|
+
```typescript
|
|
164
|
+
// src/models/cart.model.ts
|
|
165
|
+
import {
|
|
166
|
+
BaseEntity,
|
|
167
|
+
createRelations,
|
|
168
|
+
generateIdColumnDefs,
|
|
169
|
+
generateTzColumnDefs,
|
|
170
|
+
model,
|
|
171
|
+
TTableObject,
|
|
172
|
+
} from '@venizia/ignis';
|
|
173
|
+
import { pgTable, text, varchar, integer } from 'drizzle-orm/pg-core';
|
|
174
|
+
import { productTable } from './product.model';
|
|
175
|
+
|
|
176
|
+
export const cartTable = pgTable('Cart', {
|
|
177
|
+
...generateIdColumnDefs({ id: { dataType: 'string' } }),
|
|
178
|
+
...generateTzColumnDefs(),
|
|
179
|
+
userId: text('user_id'),
|
|
180
|
+
sessionId: varchar('session_id', { length: 255 }),
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
export const cartItemTable = pgTable('CartItem', {
|
|
184
|
+
...generateIdColumnDefs({ id: { dataType: 'string' } }),
|
|
185
|
+
...generateTzColumnDefs(),
|
|
186
|
+
cartId: text('cart_id').notNull(),
|
|
187
|
+
productId: text('product_id').notNull(),
|
|
188
|
+
quantity: integer('quantity').default(1).notNull(),
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
export const cartRelations = createRelations({
|
|
192
|
+
source: cartTable,
|
|
193
|
+
relations: [
|
|
194
|
+
{ type: 'many', name: 'items', target: () => cartItemTable, fields: ['id'], references: ['cartId'] },
|
|
195
|
+
],
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
export const cartItemRelations = createRelations({
|
|
199
|
+
source: cartItemTable,
|
|
200
|
+
relations: [
|
|
201
|
+
{ type: 'one', name: 'cart', target: () => cartTable, fields: ['cartId'], references: ['id'] },
|
|
202
|
+
{ type: 'one', name: 'product', target: () => productTable, fields: ['productId'], references: ['id'] },
|
|
203
|
+
],
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
export type TCartSchema = typeof cartTable;
|
|
207
|
+
export type TCart = TTableObject<TCartSchema>;
|
|
208
|
+
export type TCartItemSchema = typeof cartItemTable;
|
|
209
|
+
export type TCartItem = TTableObject<TCartItemSchema>;
|
|
210
|
+
|
|
211
|
+
@model({ type: 'entity' })
|
|
212
|
+
export class Cart extends BaseEntity<typeof Cart.schema> {
|
|
213
|
+
static override schema = cartTable;
|
|
214
|
+
static override relations = () => cartRelations.definitions;
|
|
215
|
+
static override TABLE_NAME = 'Cart';
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
@model({ type: 'entity' })
|
|
219
|
+
export class CartItem extends BaseEntity<typeof CartItem.schema> {
|
|
220
|
+
static override schema = cartItemTable;
|
|
221
|
+
static override relations = () => cartItemRelations.definitions;
|
|
222
|
+
static override TABLE_NAME = 'CartItem';
|
|
223
|
+
}
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
### Order Model
|
|
227
|
+
|
|
228
|
+
```typescript
|
|
229
|
+
// src/models/order.model.ts
|
|
230
|
+
import {
|
|
231
|
+
BaseEntity,
|
|
232
|
+
createRelations,
|
|
233
|
+
generateIdColumnDefs,
|
|
234
|
+
generateTzColumnDefs,
|
|
235
|
+
model,
|
|
236
|
+
TTableObject,
|
|
237
|
+
} from '@venizia/ignis';
|
|
238
|
+
import { pgTable, text, varchar, decimal, integer, jsonb } from 'drizzle-orm/pg-core';
|
|
239
|
+
import { productTable } from './product.model';
|
|
240
|
+
|
|
241
|
+
export const orderTable = pgTable('Order', {
|
|
242
|
+
...generateIdColumnDefs({ id: { dataType: 'string' } }),
|
|
243
|
+
...generateTzColumnDefs(),
|
|
244
|
+
userId: text('user_id'),
|
|
245
|
+
email: varchar('email', { length: 255 }).notNull(),
|
|
246
|
+
status: varchar('status', { length: 50 }).default('pending').notNull(),
|
|
247
|
+
subtotal: decimal('subtotal', { precision: 10, scale: 2 }).notNull(),
|
|
248
|
+
tax: decimal('tax', { precision: 10, scale: 2 }).default('0').notNull(),
|
|
249
|
+
shipping: decimal('shipping', { precision: 10, scale: 2 }).default('0').notNull(),
|
|
250
|
+
total: decimal('total', { precision: 10, scale: 2 }).notNull(),
|
|
251
|
+
shippingAddress: jsonb('shipping_address'),
|
|
252
|
+
billingAddress: jsonb('billing_address'),
|
|
253
|
+
paymentIntentId: varchar('payment_intent_id', { length: 255 }),
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
export const orderItemTable = pgTable('OrderItem', {
|
|
257
|
+
...generateIdColumnDefs({ id: { dataType: 'string' } }),
|
|
258
|
+
orderId: text('order_id').notNull(),
|
|
259
|
+
productId: text('product_id').notNull(),
|
|
260
|
+
name: varchar('name', { length: 255 }).notNull(),
|
|
261
|
+
price: decimal('price', { precision: 10, scale: 2 }).notNull(),
|
|
262
|
+
quantity: integer('quantity').notNull(),
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
export const orderRelations = createRelations({
|
|
266
|
+
source: orderTable,
|
|
267
|
+
relations: [
|
|
268
|
+
{ type: 'many', name: 'items', target: () => orderItemTable, fields: ['id'], references: ['orderId'] },
|
|
269
|
+
],
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
export const orderItemRelations = createRelations({
|
|
273
|
+
source: orderItemTable,
|
|
274
|
+
relations: [
|
|
275
|
+
{ type: 'one', name: 'order', target: () => orderTable, fields: ['orderId'], references: ['id'] },
|
|
276
|
+
{ type: 'one', name: 'product', target: () => productTable, fields: ['productId'], references: ['id'] },
|
|
277
|
+
],
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
export type TOrderSchema = typeof orderTable;
|
|
281
|
+
export type TOrder = TTableObject<TOrderSchema>;
|
|
282
|
+
export type TOrderItemSchema = typeof orderItemTable;
|
|
283
|
+
export type TOrderItem = TTableObject<TOrderItemSchema>;
|
|
284
|
+
|
|
285
|
+
@model({ type: 'entity' })
|
|
286
|
+
export class Order extends BaseEntity<typeof Order.schema> {
|
|
287
|
+
static override schema = orderTable;
|
|
288
|
+
static override relations = () => orderRelations.definitions;
|
|
289
|
+
static override TABLE_NAME = 'Order';
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
@model({ type: 'entity' })
|
|
293
|
+
export class OrderItem extends BaseEntity<typeof OrderItem.schema> {
|
|
294
|
+
static override schema = orderItemTable;
|
|
295
|
+
static override relations = () => orderItemRelations.definitions;
|
|
296
|
+
static override TABLE_NAME = 'OrderItem';
|
|
297
|
+
}
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
### Models Index
|
|
301
|
+
|
|
302
|
+
```typescript
|
|
303
|
+
// src/models/index.ts
|
|
304
|
+
export * from './category.model';
|
|
305
|
+
export * from './product.model';
|
|
306
|
+
export * from './cart.model';
|
|
307
|
+
export * from './order.model';
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
## 3. DataSource
|
|
311
|
+
|
|
312
|
+
```typescript
|
|
313
|
+
// src/datasources/postgres.datasource.ts
|
|
314
|
+
import {
|
|
315
|
+
BaseDataSource,
|
|
316
|
+
datasource,
|
|
317
|
+
TNodePostgresConnector,
|
|
318
|
+
ValueOrPromise,
|
|
319
|
+
} from '@venizia/ignis';
|
|
320
|
+
import { drizzle } from 'drizzle-orm/node-postgres';
|
|
321
|
+
import { Pool } from 'pg';
|
|
322
|
+
|
|
323
|
+
interface IDSConfigs {
|
|
324
|
+
host: string;
|
|
325
|
+
port: number;
|
|
326
|
+
database: string;
|
|
327
|
+
user: string;
|
|
328
|
+
password: string;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
@datasource({ driver: 'node-postgres' })
|
|
332
|
+
export class PostgresDataSource extends BaseDataSource<TNodePostgresConnector, IDSConfigs> {
|
|
333
|
+
constructor() {
|
|
334
|
+
super({
|
|
335
|
+
name: PostgresDataSource.name,
|
|
336
|
+
config: {
|
|
337
|
+
host: process.env.APP_ENV_POSTGRES_HOST ?? 'localhost',
|
|
338
|
+
port: +(process.env.APP_ENV_POSTGRES_PORT ?? 5432),
|
|
339
|
+
database: process.env.APP_ENV_POSTGRES_DATABASE ?? 'ecommerce_db',
|
|
340
|
+
user: process.env.APP_ENV_POSTGRES_USERNAME ?? 'postgres',
|
|
341
|
+
password: process.env.APP_ENV_POSTGRES_PASSWORD ?? '',
|
|
342
|
+
},
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
override configure(): ValueOrPromise<void> {
|
|
347
|
+
const schema = this.getSchema();
|
|
348
|
+
|
|
349
|
+
this.logger.debug(
|
|
350
|
+
'[configure] Auto-discovered schema | Schema + Relations (%s): %o',
|
|
351
|
+
Object.keys(schema).length,
|
|
352
|
+
Object.keys(schema),
|
|
353
|
+
);
|
|
354
|
+
|
|
355
|
+
const client = new Pool(this.settings);
|
|
356
|
+
this.connector = drizzle({ client, schema });
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
## 4. Repositories
|
|
362
|
+
|
|
363
|
+
### Product Repository
|
|
364
|
+
|
|
365
|
+
```typescript
|
|
366
|
+
// src/repositories/product.repository.ts
|
|
367
|
+
import { Product } from '@/models/product.model';
|
|
368
|
+
import { PostgresDataSource } from '@/datasources/postgres.datasource';
|
|
369
|
+
import { DefaultCRUDRepository, repository } from '@venizia/ignis';
|
|
370
|
+
|
|
371
|
+
@repository({ model: Product, dataSource: PostgresDataSource })
|
|
372
|
+
export class ProductRepository extends DefaultCRUDRepository<typeof Product.schema> {}
|
|
373
|
+
```
|
|
374
|
+
|
|
375
|
+
### Category Repository
|
|
376
|
+
|
|
377
|
+
```typescript
|
|
378
|
+
// src/repositories/category.repository.ts
|
|
379
|
+
import { Category } from '@/models/category.model';
|
|
380
|
+
import { PostgresDataSource } from '@/datasources/postgres.datasource';
|
|
381
|
+
import { DefaultCRUDRepository, repository } from '@venizia/ignis';
|
|
382
|
+
|
|
383
|
+
@repository({ model: Category, dataSource: PostgresDataSource })
|
|
384
|
+
export class CategoryRepository extends DefaultCRUDRepository<typeof Category.schema> {}
|
|
385
|
+
```
|
|
386
|
+
|
|
387
|
+
### Cart Repository
|
|
388
|
+
|
|
389
|
+
```typescript
|
|
390
|
+
// src/repositories/cart.repository.ts
|
|
391
|
+
import { Cart, CartItem } from '@/models/cart.model';
|
|
392
|
+
import { PostgresDataSource } from '@/datasources/postgres.datasource';
|
|
393
|
+
import { DefaultCRUDRepository, repository, inject } from '@venizia/ignis';
|
|
394
|
+
|
|
395
|
+
@repository({ model: CartItem, dataSource: PostgresDataSource })
|
|
396
|
+
export class CartItemRepository extends DefaultCRUDRepository<typeof CartItem.schema> {}
|
|
397
|
+
|
|
398
|
+
@repository({ model: Cart, dataSource: PostgresDataSource })
|
|
399
|
+
export class CartRepository extends DefaultCRUDRepository<typeof Cart.schema> {
|
|
400
|
+
constructor(
|
|
401
|
+
// First parameter MUST be DataSource injection
|
|
402
|
+
@inject({ key: 'datasources.PostgresDataSource' })
|
|
403
|
+
dataSource: PostgresDataSource,
|
|
404
|
+
|
|
405
|
+
// From 2nd parameter, inject additional dependencies
|
|
406
|
+
@inject({ key: 'repositories.CartItemRepository' })
|
|
407
|
+
private _cartItemRepo: CartItemRepository,
|
|
408
|
+
) {
|
|
409
|
+
super(dataSource);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
async findCartItem(opts: { cartId: string; productId: string }) {
|
|
413
|
+
return this._cartItemRepo.findOne({
|
|
414
|
+
where: { cartId: opts.cartId, productId: opts.productId },
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
async addCartItem(opts: { cartId: string; productId: string; quantity: number }) {
|
|
419
|
+
return this._cartItemRepo.create(opts);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
async updateCartItem(opts: { itemId: string; data: { quantity: number } }) {
|
|
423
|
+
return this._cartItemRepo.updateById(opts.itemId, opts.data);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
async deleteCartItem(opts: { itemId: string }) {
|
|
427
|
+
return this._cartItemRepo.deleteById(opts.itemId);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
async getCartItems(opts: { cartId: string }) {
|
|
431
|
+
return this._cartItemRepo.find({ where: { cartId: opts.cartId } });
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
async clearCart(opts: { cartId: string }) {
|
|
435
|
+
return this._cartItemRepo.deleteAll({ where: { cartId: opts.cartId } });
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
### Order Repository
|
|
441
|
+
|
|
442
|
+
```typescript
|
|
443
|
+
// src/repositories/order.repository.ts
|
|
444
|
+
import { Order, OrderItem } from '@/models/order.model';
|
|
445
|
+
import { PostgresDataSource } from '@/datasources/postgres.datasource';
|
|
446
|
+
import { DefaultCRUDRepository, repository, inject } from '@venizia/ignis';
|
|
447
|
+
|
|
448
|
+
@repository({ model: OrderItem, dataSource: PostgresDataSource })
|
|
449
|
+
export class OrderItemRepository extends DefaultCRUDRepository<typeof OrderItem.schema> {}
|
|
450
|
+
|
|
451
|
+
@repository({ model: Order, dataSource: PostgresDataSource })
|
|
452
|
+
export class OrderRepository extends DefaultCRUDRepository<typeof Order.schema> {
|
|
453
|
+
constructor(
|
|
454
|
+
// First parameter MUST be DataSource injection
|
|
455
|
+
@inject({ key: 'datasources.PostgresDataSource' })
|
|
456
|
+
dataSource: PostgresDataSource,
|
|
457
|
+
|
|
458
|
+
// From 2nd parameter, inject additional dependencies
|
|
459
|
+
@inject({ key: 'repositories.OrderItemRepository' })
|
|
460
|
+
private _orderItemRepo: OrderItemRepository,
|
|
461
|
+
) {
|
|
462
|
+
super(dataSource);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
async createOrderItem(opts: {
|
|
466
|
+
orderId: string;
|
|
467
|
+
productId: string;
|
|
468
|
+
name: string;
|
|
469
|
+
price: string;
|
|
470
|
+
quantity: number;
|
|
471
|
+
}) {
|
|
472
|
+
return this._orderItemRepo.create(opts);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
async getOrderItems(opts: { orderId: string }) {
|
|
476
|
+
return this._orderItemRepo.find({ where: { orderId: opts.orderId } });
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
```
|
|
480
|
+
|
|
481
|
+
## 5. Product Service with Inventory
|
|
482
|
+
|
|
483
|
+
```typescript
|
|
484
|
+
// src/services/product.service.ts
|
|
485
|
+
import { injectable, inject } from '@venizia/ignis';
|
|
486
|
+
import { BaseService } from '@venizia/ignis';
|
|
487
|
+
import { ProductRepository } from '../repositories/product.repository';
|
|
488
|
+
import { getError } from '@venizia/ignis-helpers';
|
|
489
|
+
|
|
490
|
+
@injectable()
|
|
491
|
+
export class ProductService extends BaseService {
|
|
492
|
+
constructor(
|
|
493
|
+
@inject('repositories.ProductRepository')
|
|
494
|
+
private _productRepo: ProductRepository,
|
|
495
|
+
) {
|
|
496
|
+
super({ scope: ProductService.name });
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
async getActiveProducts(opts: { categoryId?: string; limit?: number; offset?: number }) {
|
|
500
|
+
return this._productRepo.find({
|
|
501
|
+
where: {
|
|
502
|
+
isActive: true,
|
|
503
|
+
...(opts.categoryId && { categoryId: opts.categoryId }),
|
|
504
|
+
},
|
|
505
|
+
orderBy: { createdAt: 'desc' },
|
|
506
|
+
limit: opts.limit ?? 20,
|
|
507
|
+
offset: opts.offset ?? 0,
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
async getProductById(opts: { id: string }) {
|
|
512
|
+
const product = await this._productRepo.findById(opts.id);
|
|
513
|
+
if (!product) {
|
|
514
|
+
throw getError({ statusCode: 404, message: 'Product not found' });
|
|
515
|
+
}
|
|
516
|
+
return product;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
async checkStock(opts: { productId: string; quantity: number }): Promise<boolean> {
|
|
520
|
+
const product = await this.getProductById({ id: opts.productId });
|
|
521
|
+
return product.stock >= opts.quantity;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
async reserveStock(opts: { productId: string; quantity: number }) {
|
|
525
|
+
const product = await this.getProductById({ id: opts.productId });
|
|
526
|
+
|
|
527
|
+
if (product.stock < opts.quantity) {
|
|
528
|
+
throw getError({
|
|
529
|
+
statusCode: 400,
|
|
530
|
+
message: `Insufficient stock. Available: ${product.stock}`,
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
await this._productRepo.updateById(opts.productId, {
|
|
535
|
+
stock: product.stock - opts.quantity,
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
async releaseStock(opts: { productId: string; quantity: number }) {
|
|
540
|
+
const product = await this.getProductById({ id: opts.productId });
|
|
541
|
+
await this._productRepo.updateById(opts.productId, {
|
|
542
|
+
stock: product.stock + opts.quantity,
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
```
|
|
547
|
+
|
|
548
|
+
## 6. Cart Service
|
|
549
|
+
|
|
550
|
+
```typescript
|
|
551
|
+
// src/services/cart.service.ts
|
|
552
|
+
import { injectable, inject } from '@venizia/ignis';
|
|
553
|
+
import { BaseService } from '@venizia/ignis';
|
|
554
|
+
import { CartRepository } from '../repositories/cart.repository';
|
|
555
|
+
import { ProductService } from './product.service';
|
|
556
|
+
import { getError } from '@venizia/ignis-helpers';
|
|
557
|
+
|
|
558
|
+
interface ICartItem {
|
|
559
|
+
productId: string;
|
|
560
|
+
quantity: number;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
@injectable()
|
|
564
|
+
export class CartService extends BaseService {
|
|
565
|
+
constructor(
|
|
566
|
+
@inject('repositories.CartRepository')
|
|
567
|
+
private _cartRepo: CartRepository,
|
|
568
|
+
@inject('services.ProductService')
|
|
569
|
+
private _productService: ProductService,
|
|
570
|
+
) {
|
|
571
|
+
super({ scope: CartService.name });
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
async getOrCreateCart(opts: { userId?: string; sessionId?: string }) {
|
|
575
|
+
// Try to find existing cart
|
|
576
|
+
let cart = await this._cartRepo.findOne({
|
|
577
|
+
where: opts.userId
|
|
578
|
+
? { userId: opts.userId }
|
|
579
|
+
: { sessionId: opts.sessionId },
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
if (!cart) {
|
|
583
|
+
cart = await this._cartRepo.create({
|
|
584
|
+
userId: opts.userId,
|
|
585
|
+
sessionId: opts.sessionId,
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
return cart;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
async addItem(opts: { cartId: string; productId: string; quantity?: number }) {
|
|
593
|
+
const quantity = opts.quantity ?? 1;
|
|
594
|
+
// Validate product exists and has stock
|
|
595
|
+
const product = await this._productService.getProductById({ id: opts.productId });
|
|
596
|
+
|
|
597
|
+
if (!product.isActive) {
|
|
598
|
+
throw getError({ statusCode: 400, message: 'Product is not available' });
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
if (product.stock < quantity) {
|
|
602
|
+
throw getError({ statusCode: 400, message: 'Insufficient stock' });
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// Check if item already in cart
|
|
606
|
+
const existingItem = await this._cartRepo.findCartItem({ cartId: opts.cartId, productId: opts.productId });
|
|
607
|
+
|
|
608
|
+
if (existingItem) {
|
|
609
|
+
// Update quantity
|
|
610
|
+
const newQuantity = existingItem.quantity + quantity;
|
|
611
|
+
if (product.stock < newQuantity) {
|
|
612
|
+
throw getError({ statusCode: 400, message: 'Insufficient stock for requested quantity' });
|
|
613
|
+
}
|
|
614
|
+
return this._cartRepo.updateCartItem({ itemId: existingItem.id, data: { quantity: newQuantity } });
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// Add new item
|
|
618
|
+
return this._cartRepo.addCartItem({
|
|
619
|
+
cartId: opts.cartId,
|
|
620
|
+
productId: opts.productId,
|
|
621
|
+
quantity,
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
async updateItemQuantity(opts: { cartId: string; productId: string; quantity: number }) {
|
|
626
|
+
if (opts.quantity <= 0) {
|
|
627
|
+
return this.removeItem({ cartId: opts.cartId, productId: opts.productId });
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
const product = await this._productService.getProductById({ id: opts.productId });
|
|
631
|
+
if (product.stock < opts.quantity) {
|
|
632
|
+
throw getError({ statusCode: 400, message: 'Insufficient stock' });
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
const item = await this._cartRepo.findCartItem({ cartId: opts.cartId, productId: opts.productId });
|
|
636
|
+
if (!item) {
|
|
637
|
+
throw getError({ statusCode: 404, message: 'Item not in cart' });
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
return this._cartRepo.updateCartItem({ itemId: item.id, data: { quantity: opts.quantity } });
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
async removeItem(opts: { cartId: string; productId: string }) {
|
|
644
|
+
const item = await this._cartRepo.findCartItem({ cartId: opts.cartId, productId: opts.productId });
|
|
645
|
+
if (item) {
|
|
646
|
+
await this._cartRepo.deleteCartItem({ itemId: item.id });
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
async getCartWithItems(opts: { cartId: string }) {
|
|
651
|
+
const cart = await this._cartRepo.findById(opts.cartId);
|
|
652
|
+
if (!cart) {
|
|
653
|
+
throw getError({ statusCode: 404, message: 'Cart not found' });
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
const items = await this._cartRepo.getCartItems({ cartId: opts.cartId });
|
|
657
|
+
|
|
658
|
+
// Calculate totals
|
|
659
|
+
let subtotal = 0;
|
|
660
|
+
const itemsWithDetails = await Promise.all(
|
|
661
|
+
items.map(async (item) => {
|
|
662
|
+
const product = await this._productService.getProductById({ id: item.productId });
|
|
663
|
+
const itemTotal = Number(product.price) * item.quantity;
|
|
664
|
+
subtotal += itemTotal;
|
|
665
|
+
|
|
666
|
+
return {
|
|
667
|
+
...item,
|
|
668
|
+
product,
|
|
669
|
+
itemTotal,
|
|
670
|
+
};
|
|
671
|
+
})
|
|
672
|
+
);
|
|
673
|
+
|
|
674
|
+
return {
|
|
675
|
+
...cart,
|
|
676
|
+
items: itemsWithDetails,
|
|
677
|
+
subtotal,
|
|
678
|
+
itemCount: items.reduce((sum, item) => sum + item.quantity, 0),
|
|
679
|
+
};
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
async clearCart(opts: { cartId: string }) {
|
|
683
|
+
await this._cartRepo.clearCart({ cartId: opts.cartId });
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
```
|
|
687
|
+
|
|
688
|
+
## 7. Order Service with Payments
|
|
689
|
+
|
|
690
|
+
```typescript
|
|
691
|
+
// src/services/order.service.ts
|
|
692
|
+
import { injectable, inject } from '@venizia/ignis';
|
|
693
|
+
import { BaseService } from '@venizia/ignis';
|
|
694
|
+
import { OrderRepository } from '../repositories/order.repository';
|
|
695
|
+
import { CartService } from './cart.service';
|
|
696
|
+
import { ProductService } from './product.service';
|
|
697
|
+
import { PaymentService } from './payment.service';
|
|
698
|
+
import { getError } from '@venizia/ignis-helpers';
|
|
699
|
+
|
|
700
|
+
interface ICreateOrderInput {
|
|
701
|
+
cartId: string;
|
|
702
|
+
email: string;
|
|
703
|
+
shippingAddress: {
|
|
704
|
+
name: string;
|
|
705
|
+
line1: string;
|
|
706
|
+
line2?: string;
|
|
707
|
+
city: string;
|
|
708
|
+
state: string;
|
|
709
|
+
postalCode: string;
|
|
710
|
+
country: string;
|
|
711
|
+
};
|
|
712
|
+
billingAddress?: typeof shippingAddress;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
@injectable()
|
|
716
|
+
export class OrderService extends BaseService {
|
|
717
|
+
constructor(
|
|
718
|
+
@inject('repositories.OrderRepository')
|
|
719
|
+
private _orderRepo: OrderRepository,
|
|
720
|
+
@inject('services.CartService')
|
|
721
|
+
private _cartService: CartService,
|
|
722
|
+
@inject('services.ProductService')
|
|
723
|
+
private _productService: ProductService,
|
|
724
|
+
@inject('services.PaymentService')
|
|
725
|
+
private _paymentService: PaymentService,
|
|
726
|
+
) {
|
|
727
|
+
super({ scope: OrderService.name });
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
async createOrder(opts: { input: ICreateOrderInput }) {
|
|
731
|
+
// Get cart with items
|
|
732
|
+
const cart = await this._cartService.getCartWithItems({ cartId: opts.input.cartId });
|
|
733
|
+
|
|
734
|
+
if (cart.items.length === 0) {
|
|
735
|
+
throw getError({ statusCode: 400, message: 'Cart is empty' });
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
// Validate stock for all items
|
|
739
|
+
for (const item of cart.items) {
|
|
740
|
+
const hasStock = await this._productService.checkStock({
|
|
741
|
+
productId: item.productId,
|
|
742
|
+
quantity: item.quantity,
|
|
743
|
+
});
|
|
744
|
+
if (!hasStock) {
|
|
745
|
+
throw getError({
|
|
746
|
+
statusCode: 400,
|
|
747
|
+
message: `Insufficient stock for ${item.product.name}`,
|
|
748
|
+
});
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
// Calculate totals
|
|
753
|
+
const subtotal = cart.subtotal;
|
|
754
|
+
const tax = subtotal * 0.1; // 10% tax
|
|
755
|
+
const shipping = subtotal > 100 ? 0 : 10; // Free shipping over $100
|
|
756
|
+
const total = subtotal + tax + shipping;
|
|
757
|
+
|
|
758
|
+
// Create payment intent
|
|
759
|
+
const paymentIntent = await this._paymentService.createPaymentIntent({
|
|
760
|
+
amount: Math.round(total * 100), // Stripe uses cents
|
|
761
|
+
currency: 'usd',
|
|
762
|
+
metadata: {
|
|
763
|
+
cartId: input.cartId,
|
|
764
|
+
email: input.email,
|
|
765
|
+
},
|
|
766
|
+
});
|
|
767
|
+
|
|
768
|
+
// Create order
|
|
769
|
+
const order = await this._orderRepo.create({
|
|
770
|
+
email: opts.input.email,
|
|
771
|
+
status: 'pending_payment',
|
|
772
|
+
subtotal: subtotal.toString(),
|
|
773
|
+
tax: tax.toString(),
|
|
774
|
+
shipping: shipping.toString(),
|
|
775
|
+
total: total.toString(),
|
|
776
|
+
shippingAddress: opts.input.shippingAddress,
|
|
777
|
+
billingAddress: opts.input.billingAddress ?? opts.input.shippingAddress,
|
|
778
|
+
paymentIntentId: paymentIntent.id,
|
|
779
|
+
});
|
|
780
|
+
|
|
781
|
+
// Create order items
|
|
782
|
+
for (const item of cart.items) {
|
|
783
|
+
await this._orderRepo.createOrderItem({
|
|
784
|
+
orderId: order.id,
|
|
785
|
+
productId: item.productId,
|
|
786
|
+
name: item.product.name,
|
|
787
|
+
price: item.product.price,
|
|
788
|
+
quantity: item.quantity,
|
|
789
|
+
});
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
return {
|
|
793
|
+
order,
|
|
794
|
+
clientSecret: paymentIntent.client_secret,
|
|
795
|
+
};
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
async confirmPayment(opts: { orderId: string; paymentIntentId: string }) {
|
|
799
|
+
const order = await this._orderRepo.findById(opts.orderId);
|
|
800
|
+
|
|
801
|
+
if (!order) {
|
|
802
|
+
throw getError({ statusCode: 404, message: 'Order not found' });
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
if (order.paymentIntentId !== opts.paymentIntentId) {
|
|
806
|
+
throw getError({ statusCode: 400, message: 'Invalid payment' });
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
// Verify payment with Stripe
|
|
810
|
+
const isSuccessful = await this._paymentService.verifyPayment({ paymentIntentId: opts.paymentIntentId });
|
|
811
|
+
|
|
812
|
+
if (!isSuccessful) {
|
|
813
|
+
throw getError({ statusCode: 400, message: 'Payment not confirmed' });
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
// Update order status
|
|
817
|
+
await this._orderRepo.updateById(opts.orderId, { status: 'paid' });
|
|
818
|
+
|
|
819
|
+
// Reserve stock for all items
|
|
820
|
+
const orderItems = await this._orderRepo.getOrderItems({ orderId: opts.orderId });
|
|
821
|
+
for (const item of orderItems) {
|
|
822
|
+
await this._productService.reserveStock({ productId: item.productId, quantity: item.quantity });
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
return this._orderRepo.findById(opts.orderId);
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
async getOrdersByUser(opts: { userId: string }) {
|
|
829
|
+
return this._orderRepo.find({
|
|
830
|
+
where: { userId: opts.userId },
|
|
831
|
+
orderBy: { createdAt: 'desc' },
|
|
832
|
+
});
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
async getOrderById(opts: { orderId: string }) {
|
|
836
|
+
const order = await this._orderRepo.findById(opts.orderId);
|
|
837
|
+
if (!order) {
|
|
838
|
+
throw getError({ statusCode: 404, message: 'Order not found' });
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
const items = await this._orderRepo.getOrderItems({ orderId: opts.orderId });
|
|
842
|
+
return { ...order, items };
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
async updateOrderStatus(opts: { orderId: string; status: string }) {
|
|
846
|
+
const validStatuses = ['pending_payment', 'paid', 'processing', 'shipped', 'delivered', 'cancelled'];
|
|
847
|
+
|
|
848
|
+
if (!validStatuses.includes(opts.status)) {
|
|
849
|
+
throw getError({ statusCode: 400, message: 'Invalid status' });
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
return this._orderRepo.updateById(opts.orderId, { status: opts.status });
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
```
|
|
856
|
+
|
|
857
|
+
## 8. Payment Service (Stripe)
|
|
858
|
+
|
|
859
|
+
```typescript
|
|
860
|
+
// src/services/payment.service.ts
|
|
861
|
+
import { injectable } from '@venizia/ignis';
|
|
862
|
+
import { BaseService } from '@venizia/ignis';
|
|
863
|
+
import Stripe from 'stripe';
|
|
864
|
+
import { EnvHelper } from '@venizia/ignis-helpers';
|
|
865
|
+
|
|
866
|
+
@injectable()
|
|
867
|
+
export class PaymentService extends BaseService {
|
|
868
|
+
private _stripe: Stripe;
|
|
869
|
+
|
|
870
|
+
constructor() {
|
|
871
|
+
super({ scope: PaymentService.name });
|
|
872
|
+
|
|
873
|
+
const secretKey = EnvHelper.get('APP_ENV_STRIPE_SECRET_KEY');
|
|
874
|
+
this._stripe = new Stripe(secretKey, {
|
|
875
|
+
apiVersion: '2023-10-16',
|
|
876
|
+
});
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
async createPaymentIntent(opts: {
|
|
880
|
+
amount: number;
|
|
881
|
+
currency: string;
|
|
882
|
+
metadata?: Record<string, string>;
|
|
883
|
+
}) {
|
|
884
|
+
return this._stripe.paymentIntents.create({
|
|
885
|
+
amount: opts.amount,
|
|
886
|
+
currency: opts.currency,
|
|
887
|
+
metadata: opts.metadata,
|
|
888
|
+
automatic_payment_methods: {
|
|
889
|
+
enabled: true,
|
|
890
|
+
},
|
|
891
|
+
});
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
async verifyPayment(opts: { paymentIntentId: string }): Promise<boolean> {
|
|
895
|
+
const paymentIntent = await this._stripe.paymentIntents.retrieve(opts.paymentIntentId);
|
|
896
|
+
return paymentIntent.status === 'succeeded';
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
async refundPayment(opts: { paymentIntentId: string; amount?: number }) {
|
|
900
|
+
return this._stripe.refunds.create({
|
|
901
|
+
payment_intent: opts.paymentIntentId,
|
|
902
|
+
amount: opts.amount, // undefined = full refund
|
|
903
|
+
});
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
async createWebhookEvent(opts: { payload: string; signature: string }) {
|
|
907
|
+
const webhookSecret = EnvHelper.get('APP_ENV_STRIPE_WEBHOOK_SECRET');
|
|
908
|
+
return this._stripe.webhooks.constructEvent(opts.payload, opts.signature, webhookSecret);
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
```
|
|
912
|
+
|
|
913
|
+
## 9. Controllers
|
|
914
|
+
|
|
915
|
+
### Product Controller
|
|
916
|
+
|
|
917
|
+
```typescript
|
|
918
|
+
// src/controllers/product.controller.ts
|
|
919
|
+
import { z } from '@hono/zod-openapi';
|
|
920
|
+
import {
|
|
921
|
+
BaseController,
|
|
922
|
+
controller,
|
|
923
|
+
get,
|
|
924
|
+
inject,
|
|
925
|
+
HTTP,
|
|
926
|
+
jsonContent,
|
|
927
|
+
TRouteContext,
|
|
928
|
+
} from '@venizia/ignis';
|
|
929
|
+
import { ProductService } from '../services/product.service';
|
|
930
|
+
|
|
931
|
+
// Define route configs with PascalCase type and SCREAMING_SNAKE_CASE keys
|
|
932
|
+
const ProductRoutes = {
|
|
933
|
+
LIST: {
|
|
934
|
+
method: HTTP.Methods.GET,
|
|
935
|
+
path: '/',
|
|
936
|
+
request: {
|
|
937
|
+
query: z.object({
|
|
938
|
+
category: z.string().optional(),
|
|
939
|
+
limit: z.string().optional(),
|
|
940
|
+
offset: z.string().optional(),
|
|
941
|
+
}),
|
|
942
|
+
},
|
|
943
|
+
responses: {
|
|
944
|
+
[HTTP.ResultCodes.RS_2.Ok]: jsonContent({
|
|
945
|
+
description: 'List of products',
|
|
946
|
+
schema: z.object({
|
|
947
|
+
products: z.array(z.any()),
|
|
948
|
+
total: z.number(),
|
|
949
|
+
}),
|
|
950
|
+
}),
|
|
951
|
+
},
|
|
952
|
+
},
|
|
953
|
+
GET_BY_ID: {
|
|
954
|
+
method: HTTP.Methods.GET,
|
|
955
|
+
path: '/:id',
|
|
956
|
+
request: {
|
|
957
|
+
params: z.object({ id: z.string().uuid() }),
|
|
958
|
+
},
|
|
959
|
+
responses: {
|
|
960
|
+
[HTTP.ResultCodes.RS_2.Ok]: jsonContent({
|
|
961
|
+
description: 'Product details',
|
|
962
|
+
schema: z.any(),
|
|
963
|
+
}),
|
|
964
|
+
},
|
|
965
|
+
},
|
|
966
|
+
} as const;
|
|
967
|
+
|
|
968
|
+
type ProductRoutes = typeof ProductRoutes;
|
|
969
|
+
|
|
970
|
+
@controller({ path: '/products' })
|
|
971
|
+
export class ProductController extends BaseController {
|
|
972
|
+
constructor(
|
|
973
|
+
@inject('services.ProductService')
|
|
974
|
+
private _productService: ProductService,
|
|
975
|
+
) {
|
|
976
|
+
super({ scope: ProductController.name, path: '/products' });
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
override binding() {}
|
|
980
|
+
|
|
981
|
+
@get({ configs: ProductRoutes.LIST })
|
|
982
|
+
async listProducts(c: TRouteContext<ProductRoutes['LIST']>) {
|
|
983
|
+
const { category, limit, offset } = c.req.valid('query');
|
|
984
|
+
|
|
985
|
+
const products = await this._productService.getActiveProducts({
|
|
986
|
+
categoryId: category,
|
|
987
|
+
limit: limit ? parseInt(limit) : undefined,
|
|
988
|
+
offset: offset ? parseInt(offset) : undefined,
|
|
989
|
+
});
|
|
990
|
+
|
|
991
|
+
return c.json({ products, total: products.length });
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
@get({ configs: ProductRoutes.GET_BY_ID })
|
|
995
|
+
async getProduct(c: TRouteContext<ProductRoutes['GET_BY_ID']>) {
|
|
996
|
+
const { id } = c.req.valid('param');
|
|
997
|
+
const product = await this._productService.getProductById({ id });
|
|
998
|
+
return c.json(product);
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
```
|
|
1002
|
+
|
|
1003
|
+
### Cart Controller
|
|
1004
|
+
|
|
1005
|
+
```typescript
|
|
1006
|
+
// src/controllers/cart.controller.ts
|
|
1007
|
+
import { z } from '@hono/zod-openapi';
|
|
1008
|
+
import {
|
|
1009
|
+
BaseController,
|
|
1010
|
+
controller,
|
|
1011
|
+
get,
|
|
1012
|
+
post,
|
|
1013
|
+
put,
|
|
1014
|
+
del,
|
|
1015
|
+
inject,
|
|
1016
|
+
HTTP,
|
|
1017
|
+
jsonContent,
|
|
1018
|
+
TRouteContext,
|
|
1019
|
+
} from '@venizia/ignis';
|
|
1020
|
+
import { CartService } from '../services/cart.service';
|
|
1021
|
+
|
|
1022
|
+
const CartRoutes = {
|
|
1023
|
+
GET: {
|
|
1024
|
+
method: HTTP.Methods.GET,
|
|
1025
|
+
path: '/',
|
|
1026
|
+
responses: {
|
|
1027
|
+
[HTTP.ResultCodes.RS_2.Ok]: jsonContent({
|
|
1028
|
+
description: 'Current cart',
|
|
1029
|
+
schema: z.any(),
|
|
1030
|
+
}),
|
|
1031
|
+
},
|
|
1032
|
+
},
|
|
1033
|
+
ADD_ITEM: {
|
|
1034
|
+
method: HTTP.Methods.POST,
|
|
1035
|
+
path: '/items',
|
|
1036
|
+
request: {
|
|
1037
|
+
body: jsonContent({
|
|
1038
|
+
schema: z.object({
|
|
1039
|
+
productId: z.string().uuid(),
|
|
1040
|
+
quantity: z.number().int().positive().default(1),
|
|
1041
|
+
}),
|
|
1042
|
+
}),
|
|
1043
|
+
},
|
|
1044
|
+
responses: {
|
|
1045
|
+
[HTTP.ResultCodes.RS_2.Created]: jsonContent({
|
|
1046
|
+
description: 'Item added to cart',
|
|
1047
|
+
schema: z.any(),
|
|
1048
|
+
}),
|
|
1049
|
+
},
|
|
1050
|
+
},
|
|
1051
|
+
UPDATE_ITEM: {
|
|
1052
|
+
method: HTTP.Methods.PUT,
|
|
1053
|
+
path: '/items/:productId',
|
|
1054
|
+
request: {
|
|
1055
|
+
params: z.object({ productId: z.string().uuid() }),
|
|
1056
|
+
body: jsonContent({
|
|
1057
|
+
schema: z.object({
|
|
1058
|
+
quantity: z.number().int().min(0),
|
|
1059
|
+
}),
|
|
1060
|
+
}),
|
|
1061
|
+
},
|
|
1062
|
+
responses: {
|
|
1063
|
+
[HTTP.ResultCodes.RS_2.Ok]: jsonContent({
|
|
1064
|
+
description: 'Item quantity updated',
|
|
1065
|
+
schema: z.any(),
|
|
1066
|
+
}),
|
|
1067
|
+
},
|
|
1068
|
+
},
|
|
1069
|
+
REMOVE_ITEM: {
|
|
1070
|
+
method: HTTP.Methods.DELETE,
|
|
1071
|
+
path: '/items/:productId',
|
|
1072
|
+
request: {
|
|
1073
|
+
params: z.object({ productId: z.string().uuid() }),
|
|
1074
|
+
},
|
|
1075
|
+
responses: {
|
|
1076
|
+
[HTTP.ResultCodes.RS_2.NoContent]: {
|
|
1077
|
+
description: 'Item removed from cart',
|
|
1078
|
+
},
|
|
1079
|
+
},
|
|
1080
|
+
},
|
|
1081
|
+
} as const;
|
|
1082
|
+
|
|
1083
|
+
type CartRoutes = typeof CartRoutes;
|
|
1084
|
+
|
|
1085
|
+
@controller({ path: '/cart' })
|
|
1086
|
+
export class CartController extends BaseController {
|
|
1087
|
+
constructor(
|
|
1088
|
+
@inject('services.CartService')
|
|
1089
|
+
private _cartService: CartService,
|
|
1090
|
+
) {
|
|
1091
|
+
super({ scope: CartController.name, path: '/cart' });
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
override binding() {}
|
|
1095
|
+
|
|
1096
|
+
@get({ configs: CartRoutes.GET })
|
|
1097
|
+
async getCart(c: TRouteContext<CartRoutes['GET']>) {
|
|
1098
|
+
const sessionId = c.req.header('X-Session-ID') ?? 'guest';
|
|
1099
|
+
const cart = await this._cartService.getOrCreateCart({ sessionId });
|
|
1100
|
+
const cartWithItems = await this._cartService.getCartWithItems({ cartId: cart.id });
|
|
1101
|
+
return c.json(cartWithItems);
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
@post({ configs: CartRoutes.ADD_ITEM })
|
|
1105
|
+
async addToCart(c: TRouteContext<CartRoutes['ADD_ITEM']>) {
|
|
1106
|
+
const sessionId = c.req.header('X-Session-ID') ?? 'guest';
|
|
1107
|
+
const { productId, quantity } = c.req.valid('json');
|
|
1108
|
+
|
|
1109
|
+
const cart = await this._cartService.getOrCreateCart({ sessionId });
|
|
1110
|
+
await this._cartService.addItem({ cartId: cart.id, productId, quantity });
|
|
1111
|
+
|
|
1112
|
+
const updatedCart = await this._cartService.getCartWithItems({ cartId: cart.id });
|
|
1113
|
+
return c.json(updatedCart, HTTP.ResultCodes.RS_2.Created);
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
@put({ configs: CartRoutes.UPDATE_ITEM })
|
|
1117
|
+
async updateCartItem(c: TRouteContext<CartRoutes['UPDATE_ITEM']>) {
|
|
1118
|
+
const sessionId = c.req.header('X-Session-ID') ?? 'guest';
|
|
1119
|
+
const { productId } = c.req.valid('param');
|
|
1120
|
+
const { quantity } = c.req.valid('json');
|
|
1121
|
+
|
|
1122
|
+
const cart = await this._cartService.getOrCreateCart({ sessionId });
|
|
1123
|
+
await this._cartService.updateItemQuantity({ cartId: cart.id, productId, quantity });
|
|
1124
|
+
|
|
1125
|
+
const updatedCart = await this._cartService.getCartWithItems({ cartId: cart.id });
|
|
1126
|
+
return c.json(updatedCart);
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
@del({ configs: CartRoutes.REMOVE_ITEM })
|
|
1130
|
+
async removeFromCart(c: TRouteContext<CartRoutes['REMOVE_ITEM']>) {
|
|
1131
|
+
const sessionId = c.req.header('X-Session-ID') ?? 'guest';
|
|
1132
|
+
const { productId } = c.req.valid('param');
|
|
1133
|
+
|
|
1134
|
+
const cart = await this._cartService.getOrCreateCart({ sessionId });
|
|
1135
|
+
await this._cartService.removeItem({ cartId: cart.id, productId });
|
|
1136
|
+
|
|
1137
|
+
return c.body(null, HTTP.ResultCodes.RS_2.NoContent);
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
```
|
|
1141
|
+
|
|
1142
|
+
### Order Controller
|
|
1143
|
+
|
|
1144
|
+
```typescript
|
|
1145
|
+
// src/controllers/order.controller.ts
|
|
1146
|
+
import { z } from '@hono/zod-openapi';
|
|
1147
|
+
import {
|
|
1148
|
+
BaseController,
|
|
1149
|
+
controller,
|
|
1150
|
+
get,
|
|
1151
|
+
post,
|
|
1152
|
+
inject,
|
|
1153
|
+
HTTP,
|
|
1154
|
+
jsonContent,
|
|
1155
|
+
TRouteContext,
|
|
1156
|
+
} from '@venizia/ignis';
|
|
1157
|
+
import { OrderService } from '../services/order.service';
|
|
1158
|
+
|
|
1159
|
+
const OrderRoutes = {
|
|
1160
|
+
CREATE: {
|
|
1161
|
+
method: HTTP.Methods.POST,
|
|
1162
|
+
path: '/',
|
|
1163
|
+
request: {
|
|
1164
|
+
body: jsonContent({
|
|
1165
|
+
schema: z.object({
|
|
1166
|
+
cartId: z.string().uuid(),
|
|
1167
|
+
email: z.string().email(),
|
|
1168
|
+
shippingAddress: z.object({
|
|
1169
|
+
name: z.string(),
|
|
1170
|
+
line1: z.string(),
|
|
1171
|
+
line2: z.string().optional(),
|
|
1172
|
+
city: z.string(),
|
|
1173
|
+
state: z.string(),
|
|
1174
|
+
postalCode: z.string(),
|
|
1175
|
+
country: z.string(),
|
|
1176
|
+
}),
|
|
1177
|
+
}),
|
|
1178
|
+
}),
|
|
1179
|
+
},
|
|
1180
|
+
responses: {
|
|
1181
|
+
[HTTP.ResultCodes.RS_2.Created]: jsonContent({
|
|
1182
|
+
description: 'Order created',
|
|
1183
|
+
schema: z.object({
|
|
1184
|
+
order: z.any(),
|
|
1185
|
+
clientSecret: z.string(),
|
|
1186
|
+
}),
|
|
1187
|
+
}),
|
|
1188
|
+
},
|
|
1189
|
+
},
|
|
1190
|
+
CONFIRM: {
|
|
1191
|
+
method: HTTP.Methods.POST,
|
|
1192
|
+
path: '/:id/confirm',
|
|
1193
|
+
request: {
|
|
1194
|
+
params: z.object({ id: z.string().uuid() }),
|
|
1195
|
+
body: jsonContent({
|
|
1196
|
+
schema: z.object({
|
|
1197
|
+
paymentIntentId: z.string(),
|
|
1198
|
+
}),
|
|
1199
|
+
}),
|
|
1200
|
+
},
|
|
1201
|
+
responses: {
|
|
1202
|
+
[HTTP.ResultCodes.RS_2.Ok]: jsonContent({
|
|
1203
|
+
description: 'Order confirmed',
|
|
1204
|
+
schema: z.any(),
|
|
1205
|
+
}),
|
|
1206
|
+
},
|
|
1207
|
+
},
|
|
1208
|
+
GET_BY_ID: {
|
|
1209
|
+
method: HTTP.Methods.GET,
|
|
1210
|
+
path: '/:id',
|
|
1211
|
+
request: {
|
|
1212
|
+
params: z.object({ id: z.string().uuid() }),
|
|
1213
|
+
},
|
|
1214
|
+
responses: {
|
|
1215
|
+
[HTTP.ResultCodes.RS_2.Ok]: jsonContent({
|
|
1216
|
+
description: 'Order details',
|
|
1217
|
+
schema: z.any(),
|
|
1218
|
+
}),
|
|
1219
|
+
},
|
|
1220
|
+
},
|
|
1221
|
+
} as const;
|
|
1222
|
+
|
|
1223
|
+
type OrderRoutes = typeof OrderRoutes;
|
|
1224
|
+
|
|
1225
|
+
@controller({ path: '/orders' })
|
|
1226
|
+
export class OrderController extends BaseController {
|
|
1227
|
+
constructor(
|
|
1228
|
+
@inject('services.OrderService')
|
|
1229
|
+
private _orderService: OrderService,
|
|
1230
|
+
) {
|
|
1231
|
+
super({ scope: OrderController.name, path: '/orders' });
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
override binding() {}
|
|
1235
|
+
|
|
1236
|
+
@post({ configs: OrderRoutes.CREATE })
|
|
1237
|
+
async createOrder(c: TRouteContext<OrderRoutes['CREATE']>) {
|
|
1238
|
+
const body = c.req.valid('json');
|
|
1239
|
+
const result = await this._orderService.createOrder({ input: body });
|
|
1240
|
+
return c.json(result, HTTP.ResultCodes.RS_2.Created);
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
@post({ configs: OrderRoutes.CONFIRM })
|
|
1244
|
+
async confirmOrder(c: TRouteContext<OrderRoutes['CONFIRM']>) {
|
|
1245
|
+
const { id: orderId } = c.req.valid('param');
|
|
1246
|
+
const { paymentIntentId } = c.req.valid('json');
|
|
1247
|
+
|
|
1248
|
+
const order = await this._orderService.confirmPayment({ orderId, paymentIntentId });
|
|
1249
|
+
return c.json(order);
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
@get({ configs: OrderRoutes.GET_BY_ID })
|
|
1253
|
+
async getOrder(c: TRouteContext<OrderRoutes['GET_BY_ID']>) {
|
|
1254
|
+
const { id: orderId } = c.req.valid('param');
|
|
1255
|
+
const order = await this._orderService.getOrderById({ orderId });
|
|
1256
|
+
return c.json(order);
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
```
|
|
1260
|
+
|
|
1261
|
+
## 10. Application Setup
|
|
1262
|
+
|
|
1263
|
+
```typescript
|
|
1264
|
+
// src/application.ts
|
|
1265
|
+
import { BaseApplication, IApplicationInfo } from '@venizia/ignis';
|
|
1266
|
+
import { HealthCheckComponent, SwaggerComponent } from '@venizia/ignis';
|
|
1267
|
+
|
|
1268
|
+
import { ProductController } from './controllers/product.controller';
|
|
1269
|
+
import { CartController } from './controllers/cart.controller';
|
|
1270
|
+
import { OrderController } from './controllers/order.controller';
|
|
1271
|
+
|
|
1272
|
+
import { ProductService } from './services/product.service';
|
|
1273
|
+
import { CartService } from './services/cart.service';
|
|
1274
|
+
import { OrderService } from './services/order.service';
|
|
1275
|
+
import { PaymentService } from './services/payment.service';
|
|
1276
|
+
|
|
1277
|
+
import { ProductRepository } from './repositories/product.repository';
|
|
1278
|
+
import { CartRepository } from './repositories/cart.repository';
|
|
1279
|
+
import { OrderRepository } from './repositories/order.repository';
|
|
1280
|
+
|
|
1281
|
+
import { PostgresDataSource } from './datasources/postgres.datasource';
|
|
1282
|
+
|
|
1283
|
+
export class EcommerceApp extends BaseApplication {
|
|
1284
|
+
getAppInfo(): IApplicationInfo {
|
|
1285
|
+
return { name: 'ecommerce-api', version: '1.0.0' };
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
staticConfigure() {}
|
|
1289
|
+
|
|
1290
|
+
preConfigure() {
|
|
1291
|
+
// DataSources
|
|
1292
|
+
this.dataSource(PostgresDataSource);
|
|
1293
|
+
|
|
1294
|
+
// Repositories
|
|
1295
|
+
this.repository(ProductRepository);
|
|
1296
|
+
this.repository(CartRepository);
|
|
1297
|
+
this.repository(OrderRepository);
|
|
1298
|
+
|
|
1299
|
+
// Services
|
|
1300
|
+
this.service(ProductService);
|
|
1301
|
+
this.service(CartService);
|
|
1302
|
+
this.service(OrderService);
|
|
1303
|
+
this.service(PaymentService);
|
|
1304
|
+
|
|
1305
|
+
// Controllers
|
|
1306
|
+
this.controller(ProductController);
|
|
1307
|
+
this.controller(CartController);
|
|
1308
|
+
this.controller(OrderController);
|
|
1309
|
+
|
|
1310
|
+
// Components
|
|
1311
|
+
this.component(HealthCheckComponent);
|
|
1312
|
+
this.component(SwaggerComponent);
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
postConfigure() {}
|
|
1316
|
+
|
|
1317
|
+
setupMiddlewares() {}
|
|
1318
|
+
}
|
|
1319
|
+
```
|
|
1320
|
+
|
|
1321
|
+
## 11. Running the Application
|
|
1322
|
+
|
|
1323
|
+
### Environment Variables
|
|
1324
|
+
|
|
1325
|
+
```bash
|
|
1326
|
+
# .env
|
|
1327
|
+
NODE_ENV=development
|
|
1328
|
+
APP_ENV_SERVER_HOST=0.0.0.0
|
|
1329
|
+
APP_ENV_SERVER_PORT=3000
|
|
1330
|
+
|
|
1331
|
+
# Database
|
|
1332
|
+
APP_ENV_POSTGRES_HOST=localhost
|
|
1333
|
+
APP_ENV_POSTGRES_PORT=5432
|
|
1334
|
+
APP_ENV_POSTGRES_USERNAME=ecommerce
|
|
1335
|
+
APP_ENV_POSTGRES_PASSWORD=password
|
|
1336
|
+
APP_ENV_POSTGRES_DATABASE=ecommerce_db
|
|
1337
|
+
|
|
1338
|
+
# Stripe
|
|
1339
|
+
APP_ENV_STRIPE_SECRET_KEY=sk_test_xxx
|
|
1340
|
+
APP_ENV_STRIPE_WEBHOOK_SECRET=whsec_xxx
|
|
1341
|
+
```
|
|
1342
|
+
|
|
1343
|
+
### Start the Server
|
|
1344
|
+
|
|
1345
|
+
```bash
|
|
1346
|
+
bun run server:dev
|
|
1347
|
+
```
|
|
1348
|
+
|
|
1349
|
+
### Test the API
|
|
1350
|
+
|
|
1351
|
+
```bash
|
|
1352
|
+
# Get products
|
|
1353
|
+
curl http://localhost:3000/api/products
|
|
1354
|
+
|
|
1355
|
+
# Add to cart
|
|
1356
|
+
curl -X POST http://localhost:3000/api/cart/items \
|
|
1357
|
+
-H "Content-Type: application/json" \
|
|
1358
|
+
-H "X-Session-ID: my-session" \
|
|
1359
|
+
-d '{"productId": "uuid-here", "quantity": 2}'
|
|
1360
|
+
|
|
1361
|
+
# Get cart
|
|
1362
|
+
curl http://localhost:3000/api/cart \
|
|
1363
|
+
-H "X-Session-ID: my-session"
|
|
1364
|
+
|
|
1365
|
+
# Create order
|
|
1366
|
+
curl -X POST http://localhost:3000/api/orders \
|
|
1367
|
+
-H "Content-Type: application/json" \
|
|
1368
|
+
-d '{
|
|
1369
|
+
"cartId": "cart-uuid",
|
|
1370
|
+
"email": "customer@example.com",
|
|
1371
|
+
"shippingAddress": {
|
|
1372
|
+
"name": "John Doe",
|
|
1373
|
+
"line1": "123 Main St",
|
|
1374
|
+
"city": "San Francisco",
|
|
1375
|
+
"state": "CA",
|
|
1376
|
+
"postalCode": "94102",
|
|
1377
|
+
"country": "US"
|
|
1378
|
+
}
|
|
1379
|
+
}'
|
|
1380
|
+
```
|
|
1381
|
+
|
|
1382
|
+
## Summary
|
|
1383
|
+
|
|
1384
|
+
You've built a complete e-commerce API with:
|
|
1385
|
+
|
|
1386
|
+
| Feature | Implementation |
|
|
1387
|
+
|---------|---------------|
|
|
1388
|
+
| Product Catalog | ProductService with filtering |
|
|
1389
|
+
| Shopping Cart | CartService with session handling |
|
|
1390
|
+
| Order Processing | OrderService with stock validation |
|
|
1391
|
+
| Payments | PaymentService with Stripe integration |
|
|
1392
|
+
| Inventory | Stock reservation on order confirmation |
|
|
1393
|
+
|
|
1394
|
+
## Next Steps
|
|
1395
|
+
|
|
1396
|
+
- Add user authentication with [Auth Component](/references/components/authentication)
|
|
1397
|
+
- Add order notifications with [Mail Component](/references/components/mail)
|
|
1398
|
+
- Add real-time updates with [Socket.IO](./realtime-chat.md)
|
|
1399
|
+
- Deploy with [Deployment Guide](/best-practices/deployment-strategies)
|