langaro-api 1.2.3 → 1.2.4
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/bin/langaro-api.js +12 -2
- package/lib/cli/documentation-templates/01-architecture-overview.md +240 -0
- package/lib/cli/documentation-templates/02-crud-layer.md +504 -0
- package/lib/cli/documentation-templates/03-models.md +362 -0
- package/lib/cli/documentation-templates/04-services.md +355 -0
- package/lib/cli/documentation-templates/05-controllers.md +395 -0
- package/lib/cli/documentation-templates/06-routes.md +268 -0
- package/lib/cli/documentation-templates/07-jobs.md +361 -0
- package/lib/cli/documentation-templates/08-tasks.md +265 -0
- package/lib/cli/documentation-templates/09-middlewares.md +238 -0
- package/lib/cli/documentation-templates/10-integrations.md +332 -0
- package/lib/cli/documentation-templates/11-config-and-bootstrap.md +352 -0
- package/lib/cli/documentation-templates/12-queues.md +205 -0
- package/lib/cli/documentation-templates/13-utils.md +281 -0
- package/lib/cli/documentation-templates/14-testing.md +315 -0
- package/lib/cli/documentation-templates/15-cli-and-scaffolding.md +344 -0
- package/lib/cli/documentation-templates/SUMMARY.md +116 -0
- package/lib/cli/init.js +30 -2
- package/package.json +1 -1
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
# Models
|
|
2
|
+
|
|
3
|
+
## Role
|
|
4
|
+
|
|
5
|
+
Models are **configuration objects** — not classes. They define how the auto-generated CRUD instance for a database table should behave: which fields to hide, which relationships to load, how to validate input, what filters are available.
|
|
6
|
+
|
|
7
|
+
**What models do:**
|
|
8
|
+
- Define field visibility (hide sensitive fields)
|
|
9
|
+
- Define relationships (append)
|
|
10
|
+
- Define validation schemas (Yup)
|
|
11
|
+
- Define search and filter behavior
|
|
12
|
+
- Configure sorting defaults
|
|
13
|
+
|
|
14
|
+
**What models do NOT do:**
|
|
15
|
+
- Contain business logic (that belongs in services)
|
|
16
|
+
- Define database schema (that's in migrations)
|
|
17
|
+
- Handle HTTP requests (that's in controllers)
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## Location & Naming
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
src/database/models/
|
|
25
|
+
├── index.js # loadModels loader (do not modify)
|
|
26
|
+
├── users.model.js # Config for 'users' table
|
|
27
|
+
├── companies.model.js # Config for 'companies' table
|
|
28
|
+
├── products_invoices.model.js # Config for 'products_invoices' table
|
|
29
|
+
└── ...
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
**Naming convention:** `{table_name}.model.js` — must match the exact MySQL table name.
|
|
33
|
+
|
|
34
|
+
**Optional:** Model files are optional. If no `.model.js` exists for a table, the loader still creates a bare CRUD instance with default configuration.
|
|
35
|
+
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
## Standard Structure
|
|
39
|
+
|
|
40
|
+
```javascript
|
|
41
|
+
const yup = require('yup');
|
|
42
|
+
|
|
43
|
+
/** @type {ModelConfig} @generated-types */
|
|
44
|
+
module.exports = {
|
|
45
|
+
// Fields never returned to the client
|
|
46
|
+
hide: ['password', 'recovery_token', 'two_fa_code'],
|
|
47
|
+
|
|
48
|
+
// Field-level configuration
|
|
49
|
+
fields: {
|
|
50
|
+
// Fields that can be omitted when creating (non-required on INSERT despite being non-nullable)
|
|
51
|
+
notRequiredOnCreate: ['id', 'created_at', 'updated_at', 'role'],
|
|
52
|
+
|
|
53
|
+
// Fields that cannot be updated after creation
|
|
54
|
+
notUpdatable: ['role', 'api_key', 'recovery_token'],
|
|
55
|
+
|
|
56
|
+
// Fields included in full-text search
|
|
57
|
+
searchableFields: ['name', 'email', 'phone'],
|
|
58
|
+
|
|
59
|
+
// Filter definitions for list endpoints
|
|
60
|
+
filterOptions: [
|
|
61
|
+
{ name: 'status', type: 'select_multiple', options: ['active', 'inactive', 'blocked'] },
|
|
62
|
+
{ name: 'role', type: 'select_multiple', options: ['user', 'admin'] },
|
|
63
|
+
{ name: 'created_at', type: 'date' },
|
|
64
|
+
{ name: 'revenue', type: 'integer' },
|
|
65
|
+
{ name: 'has_subscription', column: 'subscription_id', type: 'is_not_null' },
|
|
66
|
+
],
|
|
67
|
+
},
|
|
68
|
+
|
|
69
|
+
// Yup validation schema
|
|
70
|
+
schema: yup.object().shape({
|
|
71
|
+
email: yup.string().required().email(),
|
|
72
|
+
role: yup.string().required().oneOf(['user', 'admin']),
|
|
73
|
+
password: yup.string().required().min(6).max(20),
|
|
74
|
+
phone: yup.string().matches(/^\d{10,11}$/, 'phone must be 10-11 digits'),
|
|
75
|
+
}),
|
|
76
|
+
|
|
77
|
+
// Relationship definitions
|
|
78
|
+
append: {
|
|
79
|
+
users_permissions: 'many', // users_permissions has user_id FK
|
|
80
|
+
companies: 'one', // this table has company_id FK
|
|
81
|
+
},
|
|
82
|
+
|
|
83
|
+
// Default sort
|
|
84
|
+
sortBy: 'created_at',
|
|
85
|
+
sort: 'desc',
|
|
86
|
+
};
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
---
|
|
90
|
+
|
|
91
|
+
## Property Reference
|
|
92
|
+
|
|
93
|
+
### `hide` (string[])
|
|
94
|
+
|
|
95
|
+
Fields that are **always removed** from query results before returning to the client. Used for passwords, tokens, internal flags.
|
|
96
|
+
|
|
97
|
+
```javascript
|
|
98
|
+
hide: ['password', 'recovery_token', 'two_fa_code', 'activation_token']
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### `fields.notRequiredOnCreate` (string[])
|
|
102
|
+
|
|
103
|
+
Fields that should **not be enforced as required** during `create()` even if the database column is `NOT NULL`. Typically includes auto-generated fields.
|
|
104
|
+
|
|
105
|
+
```javascript
|
|
106
|
+
notRequiredOnCreate: ['id', 'created_at', 'updated_at', 'api_key', 'role']
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
**Always include:** `id`, `created_at`, `updated_at` (these are auto-set by the framework or database).
|
|
110
|
+
|
|
111
|
+
### `fields.notUpdatable` (string[])
|
|
112
|
+
|
|
113
|
+
Fields that **cannot be changed** via `updateWhere()` unless `allowForbiddenUpdates: true` is passed.
|
|
114
|
+
|
|
115
|
+
```javascript
|
|
116
|
+
notUpdatable: ['role', 'api_key', 'password_updated_at']
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Note: `id`, `created_at`, `updated_at` are always protected — no need to list them here.
|
|
120
|
+
|
|
121
|
+
### `fields.searchableFields` (string[])
|
|
122
|
+
|
|
123
|
+
Fields included when the `search` query parameter is used in `get()`. If not defined, search is available on all non-hidden string fields.
|
|
124
|
+
|
|
125
|
+
```javascript
|
|
126
|
+
searchableFields: ['name', 'email', 'cnpj', 'phone']
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
### `fields.filterOptions` (object[])
|
|
130
|
+
|
|
131
|
+
Defines which filters are available for list endpoints. Used by `parseAndValidateFilters` utility in controllers.
|
|
132
|
+
|
|
133
|
+
**Filter types:**
|
|
134
|
+
|
|
135
|
+
```javascript
|
|
136
|
+
filterOptions: [
|
|
137
|
+
// select_multiple: filter by multiple values (IN query)
|
|
138
|
+
{ name: 'status', type: 'select_multiple', options: ['active', 'inactive'] },
|
|
139
|
+
|
|
140
|
+
// date: filter by date range (between)
|
|
141
|
+
{ name: 'created_at', type: 'date' },
|
|
142
|
+
|
|
143
|
+
// integer: filter by numeric comparison (>=, <=)
|
|
144
|
+
{ name: 'amount', type: 'integer' },
|
|
145
|
+
|
|
146
|
+
// is_not_null: filter by presence/absence of a value
|
|
147
|
+
{ name: 'has_subscription', column: 'subscription_id', type: 'is_not_null' },
|
|
148
|
+
// The 'column' property maps the filter name to an actual DB column
|
|
149
|
+
]
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
### `schema` (Yup object)
|
|
153
|
+
|
|
154
|
+
Yup validation schema. Validated on `create()` and per-field on `updateWhere()`.
|
|
155
|
+
|
|
156
|
+
```javascript
|
|
157
|
+
schema: yup.object().shape({
|
|
158
|
+
email: yup.string().required().email(),
|
|
159
|
+
cnpj: yup.string().matches(/^\d{14}$/, 'CNPJ must be 14 digits'),
|
|
160
|
+
status: yup.string().oneOf(['active', 'inactive', 'canceled']),
|
|
161
|
+
price: yup.number().positive(),
|
|
162
|
+
})
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
### `append` (object)
|
|
166
|
+
|
|
167
|
+
Relationship definitions. See `02-crud-layer.md` for detailed relationship types.
|
|
168
|
+
|
|
169
|
+
```javascript
|
|
170
|
+
append: {
|
|
171
|
+
companies: 'one', // FK in this table → load one related record
|
|
172
|
+
products: 'many', // FK in other table → load array of related records
|
|
173
|
+
categories: 'categories_join', // Many-to-many via join table
|
|
174
|
+
}
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
### `sortBy` (string) & `sort` (string)
|
|
178
|
+
|
|
179
|
+
Default sort column and direction for `get()` queries.
|
|
180
|
+
|
|
181
|
+
```javascript
|
|
182
|
+
sortBy: 'created_at', // Default: 'id'
|
|
183
|
+
sort: 'desc', // Default: 'desc'. Options: 'asc', 'desc'
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
### `fieldsSpec` (object[])
|
|
187
|
+
|
|
188
|
+
Per-field validation specifications that supplement schema validation:
|
|
189
|
+
|
|
190
|
+
```javascript
|
|
191
|
+
fieldsSpec: [
|
|
192
|
+
{ column: 'cnpj', notEmpty: true },
|
|
193
|
+
{ column: 'status', allowedValues: ['active', 'inactive'] },
|
|
194
|
+
{ column: 'description', maxLength: 500 },
|
|
195
|
+
]
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
### `forceInteger` (string[])
|
|
199
|
+
|
|
200
|
+
Fields stored as integers (cents) but exposed as floats:
|
|
201
|
+
|
|
202
|
+
```javascript
|
|
203
|
+
forceInteger: ['price', 'amount', 'discount']
|
|
204
|
+
// DB: 2999 (cents) ↔ API: 29.99 (reais)
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
### `notSearchableFields` (string[])
|
|
208
|
+
|
|
209
|
+
Fields explicitly excluded from search even if they are string columns:
|
|
210
|
+
|
|
211
|
+
```javascript
|
|
212
|
+
notSearchableFields: ['password', 'api_key', 'recovery_token']
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
### `cacheTimeout` & `cacheEnabled`
|
|
216
|
+
|
|
217
|
+
Override default cache behavior:
|
|
218
|
+
|
|
219
|
+
```javascript
|
|
220
|
+
cacheTimeout: 600000, // 10 minutes (default: 300000 = 5 min)
|
|
221
|
+
cacheEnabled: false, // Disable caching entirely for this table
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
---
|
|
225
|
+
|
|
226
|
+
## Flow
|
|
227
|
+
|
|
228
|
+
```
|
|
229
|
+
Database Table (MySQL)
|
|
230
|
+
↓
|
|
231
|
+
Model Config (.model.js) ──→ Loader reads this
|
|
232
|
+
↓
|
|
233
|
+
CRUD Instance (auto-created by loadModels)
|
|
234
|
+
↓
|
|
235
|
+
Service extends this CRUD instance
|
|
236
|
+
↓
|
|
237
|
+
Controller calls service methods
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
The model config is consumed **once** during boot when `loadModels()` runs. Changing a model file requires restarting the server.
|
|
241
|
+
|
|
242
|
+
---
|
|
243
|
+
|
|
244
|
+
## Patterns from Real Projects
|
|
245
|
+
|
|
246
|
+
### Minimal model (just sorting):
|
|
247
|
+
```javascript
|
|
248
|
+
/** @type {ModelConfig} @generated-types */
|
|
249
|
+
module.exports = {
|
|
250
|
+
fields: {},
|
|
251
|
+
sortBy: 'created_at',
|
|
252
|
+
};
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
### Model with relationships and filters:
|
|
256
|
+
```javascript
|
|
257
|
+
/** @type {ModelConfig} @generated-types */
|
|
258
|
+
module.exports = {
|
|
259
|
+
fields: {
|
|
260
|
+
notRequiredOnCreate: ['id', 'created_at', 'updated_at', 'notes', 'position'],
|
|
261
|
+
searchableFields: ['notes', 'contact_information'],
|
|
262
|
+
filterOptions: [
|
|
263
|
+
{ name: 'pipeline_step_id', type: 'select_multiple' },
|
|
264
|
+
{ name: 'responsible_user_id', type: 'select_multiple' },
|
|
265
|
+
],
|
|
266
|
+
},
|
|
267
|
+
append: {
|
|
268
|
+
companies: 'one',
|
|
269
|
+
crm_pipeline_steps: 'one',
|
|
270
|
+
users: 'one',
|
|
271
|
+
},
|
|
272
|
+
sortBy: 'position',
|
|
273
|
+
sort: 'asc',
|
|
274
|
+
};
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
### Model with comprehensive validation and security:
|
|
278
|
+
```javascript
|
|
279
|
+
const yup = require('yup');
|
|
280
|
+
|
|
281
|
+
/** @type {ModelConfig} @generated-types */
|
|
282
|
+
module.exports = {
|
|
283
|
+
hide: [
|
|
284
|
+
'password', 'recovery_token', 'recovery_token_validity',
|
|
285
|
+
'two_fa_code', 'two_fa_code_validity', 'activation_token',
|
|
286
|
+
'otp_code', 'otp_code_validity', 'login_blocked_stage',
|
|
287
|
+
'login_blocked_until',
|
|
288
|
+
],
|
|
289
|
+
fields: {
|
|
290
|
+
notRequiredOnCreate: [
|
|
291
|
+
'id', 'created_at', 'password_updated_at', 'role', 'api_key',
|
|
292
|
+
'two_fa_enabled', 'two_fa_required', 'two_fa_code',
|
|
293
|
+
'login_blocked_stage', 'activation_token',
|
|
294
|
+
],
|
|
295
|
+
notUpdatable: ['password_updated_at', 'role', 'api_key', 'recovery_token'],
|
|
296
|
+
},
|
|
297
|
+
append: {
|
|
298
|
+
users_permissions: 'many',
|
|
299
|
+
},
|
|
300
|
+
schema: yup.object().shape({
|
|
301
|
+
email: yup.string().required().email(),
|
|
302
|
+
role: yup.string().required().oneOf(['user', 'admin']),
|
|
303
|
+
password: yup.string().required().matches(
|
|
304
|
+
/^([a-zA-Z\d@$!%*#?&.,_+]{6,20})$/,
|
|
305
|
+
'password must be 6-20 characters'
|
|
306
|
+
),
|
|
307
|
+
}),
|
|
308
|
+
};
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
---
|
|
312
|
+
|
|
313
|
+
## Creating from Scratch
|
|
314
|
+
|
|
315
|
+
When creating a model for a new table:
|
|
316
|
+
|
|
317
|
+
1. **Create the file:** `src/database/models/{table_name}.model.js`
|
|
318
|
+
2. **Start with the minimal template:**
|
|
319
|
+
```javascript
|
|
320
|
+
/** @type {ModelConfig} @generated-types */
|
|
321
|
+
module.exports = {
|
|
322
|
+
fields: {},
|
|
323
|
+
sortBy: 'created_at',
|
|
324
|
+
};
|
|
325
|
+
```
|
|
326
|
+
3. **Add `notRequiredOnCreate`**: Always include `['id', 'created_at', 'updated_at']` plus any auto-generated fields
|
|
327
|
+
4. **Add `hide`**: List any sensitive fields (passwords, tokens, internal flags)
|
|
328
|
+
5. **Add `notUpdatable`**: List fields that should be immutable after creation
|
|
329
|
+
6. **Add `schema`**: Define Yup validation for fields that need format enforcement
|
|
330
|
+
7. **Add `append`**: Define relationships if the table has foreign keys or is referenced by other tables
|
|
331
|
+
8. **Add `searchableFields`**: List fields that should be searchable via the search parameter
|
|
332
|
+
9. **Add `filterOptions`**: Define filter definitions if the list endpoint needs advanced filtering
|
|
333
|
+
10. **Restart the server** to pick up the new model config
|
|
334
|
+
|
|
335
|
+
---
|
|
336
|
+
|
|
337
|
+
## Anti-patterns
|
|
338
|
+
|
|
339
|
+
- **Do NOT put business logic in model files.** Models are configuration, not behavior.
|
|
340
|
+
- **Do NOT include `table` or `knex` in the model config.** These are injected by the loader.
|
|
341
|
+
- **Do NOT forget to add `id`, `created_at`, `updated_at` to `notRequiredOnCreate`.** Missing these will cause create operations to fail with "required field" errors.
|
|
342
|
+
- **Do NOT add computed or virtual fields to `hide`.** Only hide fields that actually exist in the database table.
|
|
343
|
+
- **Do NOT define relationships that don't have matching foreign keys.** The append system relies on actual FK constraints in the database.
|
|
344
|
+
|
|
345
|
+
---
|
|
346
|
+
|
|
347
|
+
## Checklist
|
|
348
|
+
|
|
349
|
+
When creating or modifying a model:
|
|
350
|
+
|
|
351
|
+
- [ ] File is named `{exact_table_name}.model.js`
|
|
352
|
+
- [ ] File is in `src/database/models/`
|
|
353
|
+
- [ ] Exports a plain object (not a class or function)
|
|
354
|
+
- [ ] Has `@type {ModelConfig} @generated-types` JSDoc comment
|
|
355
|
+
- [ ] `notRequiredOnCreate` includes `id`, `created_at`, `updated_at`
|
|
356
|
+
- [ ] Sensitive fields are in `hide` array
|
|
357
|
+
- [ ] Immutable fields are in `notUpdatable` array
|
|
358
|
+
- [ ] Yup schema matches database column types
|
|
359
|
+
- [ ] Relationships in `append` have matching FK constraints in DB
|
|
360
|
+
- [ ] `filterOptions` types match the column data types
|
|
361
|
+
- [ ] Does NOT contain `table` or `knex` properties
|
|
362
|
+
- [ ] Does NOT contain business logic
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
# Services
|
|
2
|
+
|
|
3
|
+
## Role
|
|
4
|
+
|
|
5
|
+
Services are the **business logic layer**. They extend CRUD models with custom methods that orchestrate database operations, external API calls, and cross-table logic.
|
|
6
|
+
|
|
7
|
+
**What services do:**
|
|
8
|
+
- Implement business rules and workflows
|
|
9
|
+
- Orchestrate multi-table transactions
|
|
10
|
+
- Call external APIs via integrations
|
|
11
|
+
- Validate complex business constraints
|
|
12
|
+
- Provide methods that controllers delegate to
|
|
13
|
+
|
|
14
|
+
**What services do NOT do:**
|
|
15
|
+
- Handle HTTP request/response (that's controllers)
|
|
16
|
+
- Define field visibility or validation schema (that's models)
|
|
17
|
+
- Route URLs (that's routes)
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## Location & Naming
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
src/database/services/
|
|
25
|
+
├── index.js # loadServices loader (do not modify)
|
|
26
|
+
├── users.services.js # Service for 'users' table
|
|
27
|
+
├── companies.services.js # Service for 'companies' table
|
|
28
|
+
├── products_invoices.services.js # Service for 'products_invoices' table
|
|
29
|
+
├── checkout.services.js # Custom service (no DB table)
|
|
30
|
+
└── ...
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
**Naming convention:** `{table_name}.services.js`
|
|
34
|
+
|
|
35
|
+
**Service instance key:** `PascalCase(table_name) + 'Services'`
|
|
36
|
+
- `users.services.js` → `UsersServices`
|
|
37
|
+
- `products_invoices.services.js` → `ProductsInvoicesServices`
|
|
38
|
+
|
|
39
|
+
**Optional:** Service files are optional. If no `.services.js` exists for a table, the loader creates a bare service that only exposes the inherited CRUD methods.
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
## Standard Structure
|
|
44
|
+
|
|
45
|
+
### Table-Backed Service (most common)
|
|
46
|
+
|
|
47
|
+
This service is bound to a specific database table and inherits all CRUD methods:
|
|
48
|
+
|
|
49
|
+
```javascript
|
|
50
|
+
/* eslint-disable new-cap */
|
|
51
|
+
/** @param {typeof UsersServicesBase} model @param {ModelsConstructorMap} models @generated-types */
|
|
52
|
+
module.exports = (model, models, io) => class extends model {
|
|
53
|
+
|
|
54
|
+
async customMethod(data) {
|
|
55
|
+
// 'this' inherits all CRUD methods: get, getWhere, create, etc.
|
|
56
|
+
// But for operations needing their own instance, create one:
|
|
57
|
+
const UsersModel = new model();
|
|
58
|
+
const CompaniesModel = new models.companies();
|
|
59
|
+
|
|
60
|
+
// Access other models for cross-table operations
|
|
61
|
+
const { data: company } = await CompaniesModel.getWhere('id', data.company_id, {
|
|
62
|
+
firstOnly: true,
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// Use transactions for multi-step operations
|
|
66
|
+
const trx = await UsersModel.createTransaction();
|
|
67
|
+
try {
|
|
68
|
+
const result = await UsersModel.create(data, trx);
|
|
69
|
+
await CompaniesModel.updateWhere('id', data.company_id, {
|
|
70
|
+
user_count: company.user_count + 1,
|
|
71
|
+
}, {}, trx);
|
|
72
|
+
await trx.commit();
|
|
73
|
+
return result;
|
|
74
|
+
} catch (error) {
|
|
75
|
+
await trx.rollback();
|
|
76
|
+
throw error;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
**Factory signature:** `(model, models, io) => class extends model`
|
|
83
|
+
- `model` — The CRUD class constructor for this table (e.g., the Users CRUD class)
|
|
84
|
+
- `models` — Map of ALL model constructors (keyed by table name)
|
|
85
|
+
- `io` — Socket.io server instance
|
|
86
|
+
|
|
87
|
+
### Custom Service (no DB table)
|
|
88
|
+
|
|
89
|
+
For services that orchestrate across multiple tables without having their own:
|
|
90
|
+
|
|
91
|
+
```javascript
|
|
92
|
+
/** @param {ModelsConstructorMap} models @generated-types */
|
|
93
|
+
module.exports = (models, io) => class {
|
|
94
|
+
async processCheckout(data) {
|
|
95
|
+
const OrdersModel = new models.orders();
|
|
96
|
+
const PaymentsModel = new models.payments();
|
|
97
|
+
|
|
98
|
+
// Cross-table orchestration
|
|
99
|
+
const trx = await OrdersModel.createTransaction();
|
|
100
|
+
try {
|
|
101
|
+
const order = await OrdersModel.create(data.order, trx);
|
|
102
|
+
await PaymentsModel.create({ order_id: order.data.id, ...data.payment }, trx);
|
|
103
|
+
await trx.commit();
|
|
104
|
+
return order;
|
|
105
|
+
} catch (error) {
|
|
106
|
+
await trx.rollback();
|
|
107
|
+
throw error;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
**Factory signature:** `(models, io) => class` (only 2 arguments, no `model`)
|
|
114
|
+
|
|
115
|
+
The loader detects this variant automatically by checking the function's parameter count.
|
|
116
|
+
|
|
117
|
+
---
|
|
118
|
+
|
|
119
|
+
## Accessing Models Inside Services
|
|
120
|
+
|
|
121
|
+
**Critical rule:** Always create model instances **inside methods**, not at class level.
|
|
122
|
+
|
|
123
|
+
```javascript
|
|
124
|
+
// ✅ CORRECT: Create inside method
|
|
125
|
+
async myMethod(data) {
|
|
126
|
+
const UsersModel = new model();
|
|
127
|
+
const CompaniesModel = new models.companies();
|
|
128
|
+
// ...
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ❌ WRONG: Create at class level
|
|
132
|
+
module.exports = (model, models) => {
|
|
133
|
+
const UsersModel = new model(); // This gets shared across all calls!
|
|
134
|
+
return class extends model { ... };
|
|
135
|
+
};
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
**Why:** Each model instance manages its own connection context. Shared instances can cause transaction leaks and race conditions.
|
|
139
|
+
|
|
140
|
+
---
|
|
141
|
+
|
|
142
|
+
## Transaction Patterns
|
|
143
|
+
|
|
144
|
+
### Standard Transaction
|
|
145
|
+
|
|
146
|
+
```javascript
|
|
147
|
+
async createUserWithPermissions(data) {
|
|
148
|
+
const UsersModel = new model();
|
|
149
|
+
const PermissionsModel = new models.users_permissions();
|
|
150
|
+
|
|
151
|
+
const trx = await UsersModel.createTransaction();
|
|
152
|
+
try {
|
|
153
|
+
const user = await UsersModel.create({
|
|
154
|
+
...data,
|
|
155
|
+
role: 'user',
|
|
156
|
+
activation_token: generateToken(),
|
|
157
|
+
}, trx);
|
|
158
|
+
|
|
159
|
+
const permissions = subjects.map(subject => ({
|
|
160
|
+
subject,
|
|
161
|
+
action: '*',
|
|
162
|
+
user_id: user.data.id,
|
|
163
|
+
}));
|
|
164
|
+
await PermissionsModel.batchCreate(permissions, trx);
|
|
165
|
+
|
|
166
|
+
await trx.commit();
|
|
167
|
+
return user;
|
|
168
|
+
} catch (error) {
|
|
169
|
+
await trx.rollback();
|
|
170
|
+
throw error;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
### Transaction with Validation
|
|
176
|
+
|
|
177
|
+
```javascript
|
|
178
|
+
async transferFunds(fromId, toId, amount) {
|
|
179
|
+
const AccountsModel = new model();
|
|
180
|
+
const trx = await AccountsModel.createTransaction();
|
|
181
|
+
|
|
182
|
+
try {
|
|
183
|
+
const { data: fromAccount } = await AccountsModel.getWhere('id', fromId, {
|
|
184
|
+
firstOnly: true,
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
if (fromAccount.balance < amount) {
|
|
188
|
+
throw ApiError(StatusCodes.UNPROCESSABLE_ENTITY, 'Insufficient funds');
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
await AccountsModel.updateWhere('id', fromId, {
|
|
192
|
+
balance: fromAccount.balance - amount,
|
|
193
|
+
}, { allowForbiddenUpdates: true }, trx);
|
|
194
|
+
|
|
195
|
+
await AccountsModel.updateWhere('id', toId, {
|
|
196
|
+
balance: knex.raw('balance + ?', [amount]),
|
|
197
|
+
}, { allowForbiddenUpdates: true }, trx);
|
|
198
|
+
|
|
199
|
+
await trx.commit();
|
|
200
|
+
} catch (error) {
|
|
201
|
+
await trx.rollback();
|
|
202
|
+
throw error;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
---
|
|
208
|
+
|
|
209
|
+
## Socket.io Integration
|
|
210
|
+
|
|
211
|
+
Services receive the Socket.io instance via the `io` parameter:
|
|
212
|
+
|
|
213
|
+
```javascript
|
|
214
|
+
module.exports = (model, models, io) => class extends model {
|
|
215
|
+
async updateStatus(itemId, newStatus, userId) {
|
|
216
|
+
await this.updateWhere('id', itemId, { status: newStatus });
|
|
217
|
+
|
|
218
|
+
// Notify the specific user
|
|
219
|
+
io.sockets.in(userId).emit('item:statusUpdated', {
|
|
220
|
+
id: itemId,
|
|
221
|
+
status: newStatus,
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async broadcastToCompany(companyId, event, data) {
|
|
226
|
+
// Notify all users in a company
|
|
227
|
+
io.sockets.in(companyId).emit(event, data);
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
**Room conventions:**
|
|
233
|
+
- `io.sockets.in(userId)` — Send to a specific user
|
|
234
|
+
- `io.sockets.in(companyId)` — Send to all users in a company
|
|
235
|
+
|
|
236
|
+
---
|
|
237
|
+
|
|
238
|
+
## Common Patterns
|
|
239
|
+
|
|
240
|
+
### Data Enrichment Before Response
|
|
241
|
+
|
|
242
|
+
```javascript
|
|
243
|
+
async getEnrichedInvoice(invoiceId) {
|
|
244
|
+
const { data: invoice } = await this.getWhere('id', invoiceId, {
|
|
245
|
+
firstOnly: true,
|
|
246
|
+
append: ['companies', 'products'],
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
// Add computed fields
|
|
250
|
+
invoice.total_with_tax = invoice.total + invoice.tax_amount;
|
|
251
|
+
invoice.is_overdue = new Date(invoice.due_date) < new Date();
|
|
252
|
+
|
|
253
|
+
return invoice;
|
|
254
|
+
}
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
### Batch Create
|
|
258
|
+
|
|
259
|
+
```javascript
|
|
260
|
+
async createMultipleItems(items, companyId) {
|
|
261
|
+
const data = items.map((item) => ({
|
|
262
|
+
...item,
|
|
263
|
+
company_id: companyId,
|
|
264
|
+
}));
|
|
265
|
+
|
|
266
|
+
const trx = await this.createTransaction();
|
|
267
|
+
try {
|
|
268
|
+
const result = await this.batchCreate(data, trx);
|
|
269
|
+
await trx.commit();
|
|
270
|
+
return result;
|
|
271
|
+
} catch (error) {
|
|
272
|
+
await trx.rollback();
|
|
273
|
+
throw error;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
### Parallel External Data Fetching
|
|
279
|
+
|
|
280
|
+
```javascript
|
|
281
|
+
async syncExternalData(companyIds) {
|
|
282
|
+
const [revenueData, subscriptionData] = await Promise.all([
|
|
283
|
+
externalDb.select('*').from('revenue').whereIn('company_id', companyIds),
|
|
284
|
+
externalDb.select('*').from('subscriptions').whereIn('company_id', companyIds),
|
|
285
|
+
]);
|
|
286
|
+
|
|
287
|
+
// Build maps for efficient lookup
|
|
288
|
+
const revenueMap = new Map(revenueData.map(r => [r.company_id, r]));
|
|
289
|
+
const subscriptionMap = new Map(subscriptionData.map(s => [s.company_id, s]));
|
|
290
|
+
|
|
291
|
+
const toUpdate = companyIds.map(id => ({
|
|
292
|
+
id,
|
|
293
|
+
revenue: revenueMap.get(id)?.amount || 0,
|
|
294
|
+
subscription_status: subscriptionMap.get(id)?.status || 'inactive',
|
|
295
|
+
}));
|
|
296
|
+
|
|
297
|
+
const trx = await this.createTransaction();
|
|
298
|
+
try {
|
|
299
|
+
await this.batchUpdate(toUpdate, undefined, trx);
|
|
300
|
+
await trx.commit();
|
|
301
|
+
} catch (error) {
|
|
302
|
+
await trx.rollback();
|
|
303
|
+
throw error;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
---
|
|
309
|
+
|
|
310
|
+
## Creating from Scratch
|
|
311
|
+
|
|
312
|
+
1. **Create the file:** `src/database/services/{table_name}.services.js`
|
|
313
|
+
2. **Choose the pattern:**
|
|
314
|
+
- If the service maps to a DB table: use `(model, models, io) => class extends model`
|
|
315
|
+
- If no DB table: use `(models, io) => class`
|
|
316
|
+
3. **Start with the template:**
|
|
317
|
+
```javascript
|
|
318
|
+
/** @param {typeof TableServicesBase} model @param {ModelsConstructorMap} models @generated-types */
|
|
319
|
+
module.exports = (model, models, io) => class extends model {
|
|
320
|
+
// Add methods here
|
|
321
|
+
};
|
|
322
|
+
```
|
|
323
|
+
4. **Add business methods** — each method should represent a complete business operation
|
|
324
|
+
5. **Use transactions** for any operation that touches multiple tables
|
|
325
|
+
6. **Create model instances inside methods** (not at class level)
|
|
326
|
+
7. **Regenerate types:** `npx langaro-api generate`
|
|
327
|
+
|
|
328
|
+
---
|
|
329
|
+
|
|
330
|
+
## Anti-patterns
|
|
331
|
+
|
|
332
|
+
- **Do NOT put HTTP handling in services.** No `req`, `res`, or `next`. Services receive plain data and return plain data.
|
|
333
|
+
- **Do NOT create model instances at the class level.** Always inside methods.
|
|
334
|
+
- **Do NOT forget transactions** for multi-table operations. Partial writes without transactions can leave data in inconsistent states.
|
|
335
|
+
- **Do NOT call other services from a service.** Services receive `models` (constructors), not `services` (instances). Cross-service calls should go through the controller.
|
|
336
|
+
- **Do NOT catch and swallow errors silently.** Always re-throw or let errors bubble up for proper error handling.
|
|
337
|
+
- **Do NOT mix database and HTTP concerns.** No status codes, no res.json() in services.
|
|
338
|
+
|
|
339
|
+
---
|
|
340
|
+
|
|
341
|
+
## Checklist
|
|
342
|
+
|
|
343
|
+
When creating or modifying a service:
|
|
344
|
+
|
|
345
|
+
- [ ] File is named `{table_name}.services.js`
|
|
346
|
+
- [ ] File is in `src/database/services/`
|
|
347
|
+
- [ ] Exports a factory function (not a plain class)
|
|
348
|
+
- [ ] Factory signature matches: `(model, models, io)` for table-backed, `(models, io)` for custom
|
|
349
|
+
- [ ] Has correct `@generated-types` JSDoc annotation
|
|
350
|
+
- [ ] Class extends `model` (for table-backed services)
|
|
351
|
+
- [ ] Model instances are created inside methods, not at class level
|
|
352
|
+
- [ ] Multi-table operations use transactions with try/commit/catch/rollback
|
|
353
|
+
- [ ] No HTTP concerns (req, res, status codes)
|
|
354
|
+
- [ ] No direct `require()` of other services
|
|
355
|
+
- [ ] Error handling follows project conventions (throw, don't swallow)
|