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.
@@ -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)