Appearance
00 · Shared patterns — the conventions every TMS v2 module copies
1. Summary
TMS v2 core is plain NestJS 11 on Fastify with Mongoose 9, Redis and Bull. It has no cc-core-lib. Everything modules share lives in src/common/ plus four platform modules: settings, log/activity_log, jobs/schedules and kafka. A module is one flat folder src/<snake>/. The schema extends BaseSchema, the service extends BaseService<T>, and the controller is @Controller({ path, version: '1' }) behind one global guard. Capabilities are plugged in through boot-time registries (settings, log, job targets, script entities, and permissions on feature/auth) so platform code never imports domain code. The web (store-v2) is a Next.js pages-router BFF: page → service hook → client api → /api/* Next route → server api → core.
This file describes the patterns and links to the sibling docs for the detail of each platform part: 02-auth.md, 03-logs-and-settings.md, 04-jobs-engine.md, 05-kafka.md, 06-media.md, and 07-template-export.md (being written).
| prefix in this file | repo | worktree | branch | sha | notes |
|---|---|---|---|---|---|
src/… | bigc-tms-core-v2 | /Users/thanosorn/Works/Software/tms/wt-v2-prototype-core | main-v2-prototype | 3d70475a | foundation simplify already applied (3dd1c5a0) |
auth:src/… | bigc-tms-core-v2 | /Users/thanosorn/Works/Software/tms/wt-v2-auth-core | feature/auth (not merged) | 9d9422c | diff base 3d70475a; adds permission registry, @Scoped, roles |
web:src/… | bigc-tms-store-v2 | /Users/thanosorn/Works/Software/tms/wt-v2-prototype-store | main-v2-prototype | 0851a3a | |
webauth:src/… | bigc-tms-store-v2 | /Users/thanosorn/Works/Software/tms/wt-v2-auth-store | feature/auth (not merged) | 2d49976 | diff base 0851a3a; adds withPermission/Can, store picker |
Only on feature/auth (marked [auth] below): @Resource/@Permission/@LoggedIn, PermissionRegistry, GET /permissions, the guard as APP_GUARD, 401 on a bad token, @Scoped + withScope/assignScope/asSystem, X-Store-Id, role module, permissions:types script; web withPermission, Can, usePermission, permission-keys.ts, store picker, 403/404/500 pages. @Roles exists only on the prototype and is deleted on feature/auth.
Rules sources: the core repo has .eslintrc.js (typescript-eslint recommended + prettier) and .prettierrc (singleQuote, trailingComma: all), but no CLAUDE.md, .cursor/rules or doc/rules. The store has neither lint nor rules files. The binding rules are the workspace tms/CLAUDE.md and /Users/thanosorn/Works/Software/tms/agent-task/shared/patterns/bigc-tms-core-v2.md / bigc-tms-store-v2.md. Where DEVELOPER_GUIDE.md disagrees with the code, the code wins (§11).
2. Repo layout
2.1 Core (src/)
| path | what lives there |
|---|---|
main.ts | bootstrap: Fastify, prefix, versioning, global pipe/filter/interceptors, guard (prototype only) (src/main.ts:17-54) |
app.module.ts | root module: Bull + Mongoose roots, every feature module in one flat imports list (src/app.module.ts:38-76) |
app.controller.ts / app.service.ts | /health_check |
common/base/ | BaseSchema, BaseSchemaFactory, BaseService<T> |
common/context/ | request context (AsyncLocalStorage), actor, Fastify onRequest hook |
common/decorators/ | @Public @Basic @Roles @MaskFields @HideFields and param decorators @Profile @Session @Application @Locale; [auth] permission.decorator.ts |
common/dto/ | SearchQueryDto, SearchResultDto, ListQueryDto, shared input pieces (RelateDto, CapacityDto) |
common/enums/ | enums shared by more than one module (user, delivery_order, plan, attachment, …) |
common/filters/ | MongoExceptionFilter |
common/guards/ | AuthGuard |
common/interceptors/ | MaskInterceptor, HideInterceptor |
common/schema/ | embedded sub-schemas (geo_point, time_window, user_ref, vehicle_ref, capacity) |
common/utils/ | error, filter, redis, transaction, logger, mask, crypto, running_number, external_call, body_store, kafka, queue, … |
[auth] common/permission/, common/scope/ | permission registry + GET /permissions; @Scoped store scope |
<module>/ | one folder per module, flat (no controllers/, services/ subfolders; only exception kafka/consumers/) |
2.2 File kinds inside a module folder
| file | holds | example |
|---|---|---|
<m>.module.ts | @Module: MongooseModule.forFeatureAsync, imports of platform modules, registry calls in onModuleInit | src/vehicle/vehicle.module.ts:11-38 |
<m>.schema.ts | @Schema class extends BaseSchema + export const XSchema = BaseSchemaFactory.createForClass(X) + XSchema.index(...) | src/vehicle/vehicle.schema.ts:5-38 |
<m>.service.ts | @Injectable() class XService extends BaseService<X>, business methods | src/vehicle/vehicle.service.ts:13-59 |
<m>.controller.ts | thin HTTP layer: parse, call service, return | src/vehicle/vehicle.controller.ts:19-56 |
<m>.dto.ts | class-validator input classes <Name>Input, query classes <Name>QueryDto | src/vehicle/vehicle.dto.ts:4-14 |
<m>.constants.ts | UPPER_SNAKE constants, lookup maps, queue names, collection/log keys | src/jobs/jobs.constants.ts, src/attachment/attachment.constants.ts:11 |
<m>.types.ts | type / interface only | src/kafka/kafka.types.ts, src/settings/settings.types.ts |
<m>.enum.ts | enums of one module (shared ones go to common/enums/) | src/kafka/kafka.enum.ts |
<m>.settings.ts | defineSettingRecord + defineSetting refs of the module | src/google/google.settings.ts:4-14 |
<m>_log.schema.ts | a BaseLog subclass + its LogDefinition | src/google/google_log.schema.ts:10-37 |
<m>.registry.ts | a registry the module owns and exports (platform modules only) | src/jobs/job_targets.registry.ts, src/settings/settings.registry.ts, src/script_runner/script_entities.registry.ts |
<m>.target.ts | a job target that registers into JobTargetsRegistry | src/kafka/kafka.target.ts:60-77 |
<m>.kafka_consumer.ts | an inbound consumer that registers into KafkaService | src/delivery_order/delivery_order.kafka_consumer.ts:10-33 |
<name>.payload.ts | pure builders of an outbound message body | src/delivery_order/do-update.payload.ts |
<m>.processor.ts / <lane>.worker.ts | Bull @Processor or a polling worker | src/attachment/attachment.sweep.processor.ts:21, src/jobs/fast_lane.worker.ts |
<m>.query.ts | read-side query class when reads are not BaseService (raw connection, cursor paging) | src/activity_log/activity_log.query.ts:26 |
<m>.<topic>.spec.ts / <m>.spec.ts | jest spec beside the source | src/jobs/side_effect.service.spec.ts |
<m>.it.spec.ts | integration spec that needs real services, skipped without env | src/kafka/kafka.consumer.it.spec.ts:11-14 |
<m>.fake-spec.ts | in-memory fake shared by specs (not matched by testRegex) | src/settings/settings.fake-spec.ts:4-22 |
2.3 Web (store-v2 src/)
| path | what lives there |
|---|---|
pages/<area>/<name>/index.tsx, pages/<area>/<name>/[id].tsx | UI pages (pages router) |
pages/api/<resource>/… | Next API routes (BFF) that call core |
apis/client/<resource>/{index,types}.ts | browser → /api/* wrappers |
apis/server/<resource>/index.ts | Next server → API_GATEWAY wrappers |
services/<feature>/{index,types}.ts | ahooks useRequest hooks: page state, toasts |
stores/<name>/{index,types}.ts | zustand stores (profile, setting, theme) |
components/{ui,forms,business,layouts,…}/<Name>/{index.tsx,types.ts} | component library |
constants/, enums/, hooks/, utils/, contexts/ | menu, enums, hooks (menu, session, debounce), translate, request, query |
__tests__/pages/*.test.tsx, <dir>/__tests__/*.test.ts | jest + RTL |
3. Module skeleton and registries
3.1 Declaring a module and wiring it
| step | code | where |
|---|---|---|
model token = _.snakeCase(Class.name), same string in forFeatureAsync and @InjectModel | MongooseModule.forFeatureAsync([{ name: _.snakeCase(Vehicle.name), useFactory: () => VehicleSchema }]) | src/vehicle/vehicle.module.ts:13-21, src/vehicle/vehicle.service.ts:16-17 |
collection = Mongoose plural of the token (vehicle → vehicles) unless @Schema({ collection }) names it | explicit: _settings (src/settings/settings.schema.ts:7), log keys (src/log/log.module.ts:37-38) | |
other modules' services come through imports + the owner's exports | imports: [VehicleTypeModule] | src/vehicle/vehicle.module.ts:22 |
| platform modules imported where used | JobsModule, ActivityLogModule, KafkaModule, ScriptEntitiesModule | src/delivery_order/delivery_order.module.ts:28-33 |
add the module to the single flat list in AppModule.imports (alphabetical-ish, not ordered by dependency) | src/app.module.ts:39-72 | |
global modules: only SettingsCoreModule is @Global | src/settings/settings.module.ts:10-24 |
3.2 Registries
A registry is a boot-time capability book that a platform module owns. Domain modules write into it, and the platform reads it at run time. The why (inverting platform → domain imports, not global storage, same book on every pod) is in /Users/thanosorn/Works/Software/tms/_docs/bigc-tms-core-v2/tms-v2-registry-pattern.md §3, §7.
| registry | owner / file | how a module registers | when filled | read by | duplicate / unknown |
|---|---|---|---|---|---|
| Settings | settingsRegistry singleton, src/settings/settings.registry.ts:38-111 | SettingsModule.forFeature([RECORD, ...KEYS]) in the module's imports (src/google/google.module.ts:12) | module definition time (import of the module file), before any lifecycle hook (src/settings/settings.module.ts:29-30, src/settings/settings.registry.ts:110) | SettingsService boot and get() | different owner/schema → boot Error (src/settings/settings.registry.ts:75-77, :99-105); detail → 03 §13.1 |
| Log | LogModule.forFeature(defs), src/log/log.module.ts:22-45 | LogModule.forFeature([X_LOG_DEFINITION]) in imports (src/google/google.module.ts:11) | definition time (retention setting) + provider factory (indexes, writer) | @InjectLog(key) (src/log/log.decorators.ts:5) | undeclared key → Nest boot fails on the missing token; detail → 03 §13.2 |
| Job targets | JobTargetsRegistry, src/jobs/job_targets.registry.ts:19-52 | inject the registry, register({ name, lane, handler, lossy?, delivery_mode?, exclusive? }) in onModuleInit (src/kafka/kafka.target.ts:60-77) | onModuleInit | SideEffectService.enqueueMany (src/jobs/side_effect.service.ts:52) and workers | duplicate → duplicate_target (:24); unknown at enqueue → unknown_target (:37), rolls back the caller's tx; detail → 04 §13.A |
| Schedules | SchedulesService.declare(), src/schedules/schedules.service.ts:55-65 (registers a long-lane exclusive target schedule:<code>) | inject SchedulesService, declare({ code, cron, … }) in onModuleInit | onModuleInit; row upserted in onApplicationBootstrap (:67-72) | long lane | duplicate → duplicate_schedule (:57). No module declares one at 3d70475a; detail → 04 §13.C |
| Kafka consumers | KafkaService.registerConsumer(event, handler), src/kafka/kafka.service.ts:57 | in onModuleInit of a <m>.kafka_consumer.ts provider (src/delivery_order/delivery_order.kafka_consumer.ts:20-22) | onModuleInit | consumer runner | detail → 05 §13.B |
| Script entities (also feeds field catalog and templates) | ScriptEntitiesRegistry, src/script_runner/script_entities.registry.ts:13-39 | import ScriptEntitiesModule, inject registry, register({ name, service, controller }) in onModuleInit (src/vehicle/vehicle.module.ts:35-37) | onModuleInit | lazily in ScriptRunnerService (src/script_runner/script_runner.service.ts:122,133); FieldCatalogService.onApplicationBootstrap (src/field_catalog/field_catalog.service.ts:77-78) | duplicate → boot Error (:18). Never register an entity holding secrets (:11). The controller's @HideFields/@MaskFields apply to script reads |
| [auth] Permissions | PermissionRegistry, auth:src/common/permission/permission.registry.ts:33-99 | nothing to call: put @Resource('<prefix>') on the controller class and optionally @Permission(suffix, label) / @LoggedIn() / @Public() on handlers | onModuleInit scans every controller through DiscoveryService (:44-49) | AuthGuard per request, GET /api/v1/permissions | detail → 02 §4, §13 |
Rules that hold for every registry (from the registry-pattern doc §9 and the code): register only in onModuleInit or at module definition, never from DB data or a request. Read at run time or in onApplicationBootstrap, never in your own constructor or onModuleInit (src/field_catalog/field_catalog.service.ts:77). A duplicate name fails boot, and an unknown name fails at the entry point. Env that changes registrations (KAFKA_TARGET_TOPICS, src/kafka/kafka.target.ts:64) must be the same on every pod.
4. Base layer (after the foundation simplify)
common/base was not changed by the foundation simplify; the cut list (/Users/thanosorn/Works/Software/tms/_docs/bigc-tms-core-v2/tms-v2-foundation-simplify-cut-list.md) touched kafka, jobs, settings, log and schedules only. What it left for modules is covered in 03 §2.3.
4.1 BaseSchema / BaseSchemaFactory (src/common/base/base.schema.ts)
| field / behaviour | as built | where |
|---|---|---|
_id | Mongo ObjectId; removed from JSON output | :30 |
id | virtual string of _id, in JSON (virtuals: true) | :7, :17 |
created_at, updated_at | Mongoose timestamps, snake_case (each schema repeats the option, e.g. src/vehicle/vehicle.schema.ts:5-11) | :5 |
__v | written by Mongoose, hidden from JSON (versionKey: false is a toJSON option only); not used for concurrency | :18 |
_translates | Mixed, reserved for per-field translations; nothing writes it | :9-10 |
| Decimal128 | converted to a number rounded to 2 dp in JSON (10 levels deep) | :20-31 |
created_by / updated_by | not in the base. Set by hand only in template and export_config (string user id) and _settings ({id,name}) | src/template/template.schema.ts:76-79, src/export_config/export_config.schema.ts:43-46, src/settings/settings.schema.ts:31 |
strict: true | per schema (every domain schema sets it) | src/vehicle/vehicle.schema.ts:6 |
| Log rows | extend BaseLog, not BaseSchema directly; no timestamps, at + expire_at TTL | 03 §3.2 |
4.2 BaseService<T> (src/common/base/base.service.ts)
| method | behaviour | where |
|---|---|---|
| constructor | this.logger = logger.child({ service: <ClassName> }); _searchFields = every String path of the schema | :24-42 |
search(query, searchFields?, populate?) | keyword → $or of case-insensitive $regex over string fields, _.merge with filter; countDocuments + find().select(fields).sort().skip().limit(); returns SearchResultDto | :90-131 |
list(query) | same filter, no paging; reads query.keywoard (typo) | :133-158 |
get(id) | findById | :160-173 |
find(filter, populate?, sort?) | findOne; returns {} when nothing matches, callers test _.isEmpty | :186-203, :82-88, src/vehicle/vehicle.service.ts:30-38 |
count(query) | countDocuments with keyword+filter (keyword ignored: no search fields passed) | :175-184 |
create(data, { session }) | new model(data).save() → toJSON() | :205-211 |
update(id, data, { session }) | reads current (unused), then findByIdAndUpdate(returnDocument: 'after'); last write wins | :213-225 |
delete(id, { session }) | hard delete deleteOne, returns the pre-delete JSON | :227-235 |
aggregate, deleteMany, updateMany, insertMany | Mongoose passthrough | :237-251 |
| return shape | every read goes through JSON.parse(JSON.stringify()) → toJSON (id, no _id) | :82-88 |
4.3 List query shape
param (SearchQueryDto, src/common/dto/search_query.dto.ts:3-26) | type | rule |
|---|---|---|
keywords | string | regex over every string field + extra searchFields; not escaped (base.service.ts:56) |
filter | qs-encoded string | controller must call query.filter = convertFilter(query.filter) (src/vehicle/vehicle.controller.ts:31); qs parse, 'true'/'false' → boolean, ISO strings → Date, {0:…} objects → arrays (src/common/utils/filter.ts:12-44). Any Mongo operator passes through |
sort | a,-b | - = descending (base.service.ts:67-80) |
fields | string | Mongoose select |
page | positive int | no default: missing page gives skip(NaN) (base.service.ts:105) |
page_size | positive int | default 10 (:104), no maximum |
| response | SearchResultDto { page, page_size, total, total_page, rows } | src/common/dto/search_result.dto.ts:1-7 |
The web builds this query through buildQuery (web:src/utils/request.ts:15-31, server side web:src/apis/server/request.ts:75-92). useCompactList sends filter = { $and: [userFilter, baseFilter] } (web:src/services/compact_list/index.ts:47-59).
4.4 Store scoping hook — [auth] only
| piece | behaviour | where |
|---|---|---|
@Scoped({ <scope>: '<field>' }) on the service class | marks the entity; BaseService reads it into this._scope | auth:src/common/scope/scope.ts:15-20, auth:src/common/base/base.service.ts:24 |
| reads | _buildFilter and by-id paths AND the scope filter in; out-of-scope by id → 404 not_found | auth:src/common/base/base.service.ts:67-77, :183 |
| writes | create/insertMany fill the field from the selected (or only) store, else 400 <scope>_required; a foreign value → 403 <scope>_forbidden; update operators on the field are checked | auth:src/common/scope/scope.ts:59-92 |
| aggregate | prepends a cast $match | auth:src/common/base/base.service.ts:254-258 |
| unrestricted | non-HTTP channels, system, access.all, API keys | auth:src/common/scope/scope.ts:23-24 |
asSystem(fn) | run unfiltered as the system actor, same trace | auth:src/common/scope/scope.ts:95-98 |
direct this.model.* calls bypass it | must wrap with withScope/assignScope by hand, as DO update does | auth:src/delivery_order/delivery_order.service.ts (diff +137-147) |
Scoped today: delivery_order (source_store_id), delivery_order_item (store_id), plan ×3 services, store (_id). Rules, backfill and gaps → 02 §3 "Scoped-entity fields", §13 "A new store-scoped entity".
4.5 Duplicate keys
| layer | behaviour | where |
|---|---|---|
global MongoExceptionFilter | catches MongoError 11000 → throws duplicate_unique_key 500. It throws from inside the filter, and other Mongo codes get no handling (live behaviour not verified) | src/common/filters/mongo.filters.ts:5-16 |
| helper | isDuplicateKeyError(error) (code 11000 or E11000 in message) lives in activity_log/event_key.ts and is imported by role/user/application | src/activity_log/event_key.ts:10 |
| expected duplicates are caught in the service | template → 400 duplicate_code; role → 409 duplicate_code; idempotent upserts swallow it (settings, schedules, kafka claim, user/app seed) | src/template/template.service.ts:70, auth:src/role/role.service.ts:113, src/settings/settings.service.ts:267, src/schedules/schedules.service.ts:96, src/kafka/kafka.consumer.ts:222 |
| business idempotency | unique event_key on activity_logs → ActivityAlreadyApplied; unique dedupe_key on jobs | src/activity_log/activity_log.service.ts:49-54, 04 §5 |
4.6 Optimistic concurrency
No base mechanism exists: BaseService.update is last-write-wins (base.service.ts:213-225). Three modules guard by hand with a conditional findOneAndUpdate({ _id, <rev>: input.<rev> }, { $set, $inc: { <rev>: 1 } }):
| module | field | conflict error | where |
|---|---|---|---|
| settings | rev | 409 rev_conflict, rev in body | src/settings/settings.service.ts:221-241, :358-360 |
| template | revision | 409 version_conflict | src/template/template.service.ts:38-57 |
| export_config | revision | 409 version_conflict | src/export_config/export_config.service.ts:36-45 |
| status transitions | guard on current status in the filter | 409 invalid_status | src/jobs/jobs.controller.ts:47-55 |
4.7 Transactions
inTransaction(this.model.db, async (tx) => …) (src/common/utils/transaction.ts:12-32) runs session.withTransaction. tx.afterCommit(fn) hooks run only after the commit succeeds, and their errors are logged, not thrown. Pass { session: tx.session } to BaseService writes (src/delivery_order/delivery_order.service.ts:112-122). Mongo must be a replica set.
5. Controller conventions
5.1 Request pipeline
5.2 Rules
| topic | as built | where |
|---|---|---|
| prefix | api, except GET /health_check | src/main.ts:32-34 |
| versioning | URI; every controller sets version: '1' → /api/v1/<path> | src/main.ts:35-37, src/vehicle/vehicle.controller.ts:20-23 |
| path | @Controller({ path: '<name>', version: '1' }); naming is not consistent (§11 D1) | |
| URL key | business key /:code for master data (vehicle, store, plan, template/plan, type/vehicle); /:id for newer config entities (templates, export_configs, roles, users, jobs, attachments) | §11 D2 |
| validation | global ValidationPipe({ transform: true, transformOptions: { enableImplicitConversion: true } }); no whitelist/forbidNonWhitelisted, so unknown body keys reach the service (schemas are strict, so Mongo drops unknown paths) | src/main.ts:45-50 |
| DTO | class-validator classes in <m>.dto.ts, named <Name>Input (body) / <Name>QueryDto or <Name>Input (query) | src/vehicle/vehicle.dto.ts:4, src/jobs/jobs.dto.ts:7 |
| body limit / CORS | Fastify bodyLimit: 1048576000 (≈1000 MB); @fastify/cors with defaults (any origin) | src/main.ts:19-23 |
| current user | @Profile(field?) / @Session(field?) / @Application(field?) param decorators; never @Req() | src/common/decorators/profile.decorator.ts:4-13; fit check in the patterns file |
| response envelope | none: the controller returns the entity JSON, SearchResultDto, or a module-specific object (§11 D3) | |
| status code | Nest defaults: POST 201, others 200; override with @HttpCode(200) (src/template/template.controller.ts:38-39) |
5.3 Error envelope and codes
| source | status | body |
|---|---|---|
throwError(message, code, status = 400, info?, extra?) | given, default 400 | { statusCode, code, message: string[], additional_info, error: '', ...extra } (src/common/utils/error.ts:4-19) |
ValidationPipe | 400 | Nest default { statusCode, message: string[], error: 'Bad Request' }, no code |
guard returns false (prototype) | 403 | Nest default { statusCode: 403, message: 'Forbidden resource', error: 'Forbidden' }; the web client redirects to login on that exact string (web:src/apis/client/request.ts:37-39) |
| [auth] guard | 401 unauthorized, 403 permission_denied (additional_info.key), 403 store_forbidden | auth:src/common/guards/auth.guard.ts:30,105,112 |
| duplicate key (unhandled) | 500 duplicate_unique_key | src/common/filters/mongo.filters.ts:9-14 |
| anything else thrown | 500 Nest default |
Code style: lowercase snake (not_found, invalid_input, invalid_status, version_conflict). Attachment and storage use UPPER (NOT_FOUND, FILE_TOO_LARGE, STORAGE_NOT_CONFIGURED) (§11 D5). not_found is 400 in older services and 404 in newer ones (§11 D5).
5.4 Interceptors and auth decorators
| decorator | level | effect | where |
|---|---|---|---|
@MaskFields([...]) / @IgnoreMaskField() | class / handler | MaskInterceptor masks values in an object, an array or { rows } | src/common/interceptors/mask.interceptor.ts:18-26, src/common/utils/mask.ts:36-59 |
@HideFields([...]) / @IgnoreHideField() | class / handler | HideInterceptor omits fields, same three shapes | src/common/interceptors/hide.interceptor.ts:18-27, src/common/utils/mask.ts:62-74 |
@Public() | handler (prototype guard reads the handler only, src/common/guards/auth.guard.ts:77); handler or class on auth (auth:src/common/permission/permission.registry.ts:64) | open route; a token is still read if present | |
@Basic() | handler | prototype: Basic app_key:secret_key instead of Bearer (src/common/guards/auth.guard.ts:78,83-85). Auth: chosen by the Authorization scheme, decorator unused (auth:src/common/guards/auth.guard.ts:78-86) | |
@Roles([UserRole.x]) | class / handler (handler wins) | prototype only: compares session.user.role (src/common/guards/auth.guard.ts:34,79-80) | deleted on auth |
[auth] @Resource('<prefix>') | class only | key prefix, may be dotted (setting.kafka); no resource → general.* | auth:src/common/decorators/permission.decorator.ts:13, auth:src/common/permission/permission.key.ts:3 |
[auth] @Permission(suffix, label?) | handler | overrides the verb suffix (GET read, POST create, PUT/PATCH update, DELETE delete) | auth:src/common/permission/permission.key.ts:5-20 |
[auth] @LoggedIn() | handler / class | any valid token, no key | auth:src/common/decorators/permission.decorator.ts:18 |
The interceptors do not look inside { items } or { data } (§11 D3). A controller that returns those shapes and needs masking must project the fields out itself, as users admin does with { password: 0 } (auth:src/user/user.admin.service.ts:31).
6. Cross-cutting calls a module makes
Logging (winston). BaseService subclasses use this.logger (a child named after the class, src/common/base/base.service.ts:27-29). Other classes use logger.child({ service: this.constructor.name }) (src/delivery_order/delivery_order.kafka_consumer.ts:17). Every line is JSON with timestamp and the current trace_id (src/common/utils/logger.ts:4-20). A structured log collection (rows kept in Mongo with a TTL) is declared with LogModule.forFeature([X_LOG_DEFINITION]) and written through @InjectLog(X_LOG) private log: BufferedLogWriter | TransactionalLogWriter (src/google/google.module.ts:11, src/google/google.service.ts:44). → 03 §13.2.
Activity log (business history). Import ActivityLogModule and inject ActivityLogService. Inside the business transaction call await this.activity.record(tx.session, { entity, entity_id, action, changes?: diffChanges(before, after, keys), event_key?: eventKey('<prefix>', key, changeKey), refs?, reason? }) (src/delivery_order/delivery_order.service.ts:112-122, :137-151; signature src/activity_log/activity_log.service.ts:13-24,46). Use recordStandalone only for an outcome that has no write to commit with (:59). A duplicate event_key throws ActivityAlreadyApplied and aborts the tx. → 03 §13.3.
Settings read. Declare X_SETTINGS = defineSettingRecord({ code, owner, name }) and X_KEY = defineSetting(X_SETTINGS, { key, schema: Text | Flag | <class>, default, encrypt? }) in <m>.settings.ts (src/google/google.settings.ts:4-14). Register them with SettingsModule.forFeature([X_SETTINGS, X_KEY]) in the module imports, inject SettingsService (global) and read this.settings.get(X_KEY) (sync, in-pod cache, falls back to the default) or getSecret(X_KEY) for encrypted keys (src/settings/settings.service.ts:74-91). React with onChange(X_SETTINGS, fn) (:93). → 03 §13.1.
Jobs enqueue. Import JobsModule and inject SideEffectService. Inside the transaction call await this.sideEffect.enqueue(target, payload, { code, key, version, dedupe_key, headers }, tx) or enqueueMany(items, tx) (src/jobs/side_effect.service.ts:16-22,46-51). The trace and actor come from the request context and are never passed in (:56-57). The target must already be registered (§3.2). → 04 §13.A.
Kafka outbox. Never produce directly. Enqueue a job whose target is kafkaTargetName(topic) = kafka:<topic> with code: <event>, key, version and dedupe_key: '<event>:<key>:<version>' in the same transaction as the business write (src/delivery_order/delivery_order.service.ts:168-195). Declare the event in KAFKA_EVENTS (src/kafka/kafka.constants.ts:17-22) so the target registers at boot (src/kafka/kafka.target.ts:60-68). Inbound: a <m>.kafka_consumer.ts provider calls this.kafka.registerConsumer('<event>', (m) => …) and returns a reason string for a permanent failure (src/delivery_order/delivery_order.kafka_consumer.ts:20-33). → 05 §13.A, §13.B.
Request context. Fastify onRequest opens an AsyncLocalStorage context { trace_id, actor?, channel } per request (src/common/context/context.middleware.ts:4-6, src/common/context/request_context.ts:30-58). Kafka, jobs and schedule ticks open their own. Read it with requestContext.get()/traceId(). Outbound HTTP/Kafka headers come from requestContext.outboundHeaders() (:54-57). Work no human started runs as systemActor() (src/common/context/actor.ts:28). → 03 §5.4.
Outbound HTTP. Calls to third parties go through externalCall (src/common/utils/external_call.ts), which writes the call log. → 03, 06.
7. Config, boot order, health
7.1 Env
dotenv.config() runs in main.ts:13, app.module.ts:36 and common/utils/redis.ts:5. No config module, schema or validation exists. A missing URL fails at import time for Redis (src/common/utils/redis.ts:34-36,168-169) and at connect time for Mongo. Tunables are code literals in <m>.constants.ts (03 §8.2, 04 §8, 05 §8 list them).
| env | default | effect | read at |
|---|---|---|---|
PORT | 3000 | HTTP port | src/main.ts:15 |
MONGODB_URI | — | Mongoose root (replica set needed for transactions) | src/app.module.ts:44 |
QUEUE_REDIS_URL | — | Bull root | src/app.module.ts:40-42 |
SESSION_REDIS_URL, CACHE_REDIS_URL | — (throws) | redisSession, redisCache (cache also carries settings pub/sub) | src/common/utils/redis.ts:168-169 |
DEFAULT_SYSTEM_ADMIN_PASSWORD | — | seeded login admin | src/user/user.service.ts:81 |
DEFAULT_APP_KEY, DEFAULT_SECRET_KEY | random | default application | src/application/application.service.ts:54-55 |
STORAGE_SECRET_ENC_KEY | — | AES-256-GCM key of every encrypted setting (name says storage, used by all) | src/common/utils/crypto.ts:4-21 |
JOBS_WORKERS | on | off stops both lanes and schedule ticks on this pod | src/jobs/jobs.constants.ts:40 |
KAFKA_BROKERS, KAFKA_CLIENT_ID, KAFKA_GROUP_PREFIX, KAFKA_TARGET_TOPICS | see 05 §8 | ||
SETTINGS_RELOAD_INTERVAL_MS | 60000 | full settings reload | src/settings/settings.types.ts:4 |
S3_ENDPOINT | unset | LocalStack only | src/storage_setting/storage_setting.service.ts:49-50 |
ACTIVITY_SEARCH_MAX_DAYS, LOG_BODY_CAP_BYTES, TEMPLATE_* | see 03, 07 | ||
web API_GATEWAY | — | core base URL incl. /api/v1 | web:src/apis/server/request.ts:20 |
web SYSTEM_APP_KEY, SYSTEM_SECRET_KEY | — | login application | web:src/apis/server/auth/index.ts:5-6 |
web COOKIE_SAME_SITE, COOKIE_SECURE, COOKIE_HTTP_ONLY | lax / false / false | token cookie | web:src/pages/api/auth/login.ts:13-15 |
7.2 Boot order
| fact | where |
|---|---|
| settings registry is complete before any hook | src/settings/settings.registry.ts:110 |
SettingsService boot | src/settings/settings.service.ts:56-67; get() before it finishes returns the code default (:79-80) |
system user id set in onModuleInit | src/user/user.service.ts:28-30 |
field catalog reads the script registry in onApplicationBootstrap | src/field_catalog/field_catalog.service.ts:77 |
lanes / schedules start in onApplicationBootstrap | src/jobs/fast_lane.worker.ts:143, src/jobs/long_lane.worker.ts:49, src/schedules/schedules.service.ts:67-72 |
| Kafka boot | src/kafka/kafka.service.ts:61-68 |
| shutdown hooks | src/main.ts:52; ordering → 04 §9, 05 §9 |
7.3 Health
| endpoint | auth | response | checks |
|---|---|---|---|
core GET /health_check (no prefix) | @Public | { status: true } | nothing: no Mongo, Redis or Kafka probe (src/app.controller.ts:10-14, src/app.service.ts:6-10) |
core GET /api/v1/kafka/status | setting.kafka.read on auth | per-pod Kafka status | 05 §4 |
web GET /api/health_check | none | Next route | web:src/pages/api/health_check/index.ts |
8. Testing conventions
| topic | core | web |
|---|---|---|
| runner | jest 28 + ts-jest, config in package.json (rootDir: src, testRegex: .*\.spec\.ts$, ^src/ alias) (package.json:78-97) | jest 30 via next/jest, jsdom, @/ alias, @testing-library/jest-dom (web:jest.config.js, web:jest.setup.js) |
| where | <file>.spec.ts beside the source (38 specs at 3d70475a, 54 on auth) | src/__tests__/pages/*.test.tsx, src/<dir>/__tests__/*.test.ts (2 on prototype, 13 on auth) |
| unit style | construct the class with hand-written fakes for the model ({ find: () => ({ session: () => ({ lean }) }) }), or Test.createTestingModule (10 specs); fakeSettings() for SettingsService (src/settings/settings.fake-spec.ts:4-22); jest.mock('src/common/utils/logger', …) | RTL render of pages with mocked @/apis/client/* |
| Mongo / Redis | no mongodb-memory-server or redis mock. src/app.module.spec.ts boots the real AppModule against local Mongo (APP_TEST_MONGO_URI, default mongodb://127.0.0.1:27017, throw-away db) and Redis (APP_TEST_REDIS_URL, default db 9) and always runs (:5-8). kafka.consumer.it.spec.ts needs KAFKA_IT_BROKERS + KAFKA_IT_MONGO, else describe.skip (:11-14) | none |
| test names | condition ids in titles (C1 …, C3 …), e.g. src/common/context/context.spec.ts:30-139 | |
| run | npx jest --maxWorkers=3 (one file: npx jest src/x/x.spec.ts --runInBand). npm test alone runs jest with its default worker count, which the workspace rule forbids. npm run test:e2e points at a missing test/jest-e2e.json | npx jest --maxWorkers=3 |
| snapshots | auth:src/common/scope/__snapshots__/scope.spec.ts.snap (scope map of every entity) | webauth:src/__tests__/pages/permission-keys.test.ts checks web key literals against registry-keys.json |
Do not run builds/tests on the human's running worktrees. The store :4014 caveat is in the store patterns file.
9. Web (store-v2) conventions
9.1 Layering
| layer | convention | where |
|---|---|---|
| page | pages/<area>/<name>/index.tsx, detail [id].tsx (create as the id for new); component <Area><Name>Page, default export; [auth] export default withPermission(Page, '<key>.read') | web:src/pages/master_data/store/index.tsx:12,117, webauth:src/pages/master_data/store/index.tsx:118 |
| layout | components/layouts/index.tsx picks Login or Main from useLayout(); Main has Header, Menu, Submenu | web:src/components/layouts/index.tsx:7-28 |
_app | SSR getInitialProps reads the token cookie, loads the profile and public settings into zustand, and redirects to /login?redirect_url= when there is no token. [auth] also checks Component.permission: 403 page and status, a 500 page when settings fail | web:src/pages/_app.tsx:112-136; webauth:src/pages/_app.tsx:109,126-185 |
| service hook | ahooks useRequest (auto get + manual save), toast.success(t(...)) / toast.error(e.message); page stays presentational | web:src/services/setting/index.ts:49-104 |
| client api | request(path, method, body?, isMultipart?) → /api/<path>; throws Error(message) with code, status, data; redirects to login on "Forbidden resource" | web:src/apis/client/request.ts:6-47 |
| Next route | export default async (req, res), branch on req.method, try { … res.status(200).json(r) } catch (e) { res.status(e.status || 500).json(e.error) }, 405 fallback | web:src/pages/api/settings/[code]/index.ts:5-30 |
| server api | request(path, method, body, getHeadersFromRequest(req)) → join(API_GATEWAY, path); throws { status, error: message, code, data } | web:src/apis/server/request.ts:6-49 |
| headers | Authorization: Bearer <token cookie>, X-Client-IP, User-Agent, client-ip; [auth] X-Store-Id from the store cookie | web:src/apis/server/request.ts:51-73; webauth:src/apis/server/request.ts (+63-66) |
| generic entity proxy | /api/entity/[entity] and [entity]/[key] map plan_template → template/plan, vehicle_type → type/vehicle, else the entity name | web:src/apis/server/entity/index.ts:4-27, web:src/enums/entity.ts:1-11 |
9.2 State, i18n, permission, components
| topic | convention | where |
|---|---|---|
| stores | zustand create<Props>(): profile ([auth] adds all, permissions: Set, selectedStore, can), setting (public settings), theme | web:src/stores/profile/index.ts:4-7, webauth:src/stores/profile/index.ts:4-16 |
| i18n | t(key, data?) = lodash _template(TRANSLATES[key] || key)(data); keys <feature>_<field> in one English dictionary in utils/translate.ts; setTranslate exists but _app never passes translates | web:src/utils/translate.ts:3,347-352, web:src/pages/_app.tsx:41-43 |
| menu | MENU in constants/menu.ts, { label: t('menu_…'), icon: <phosphor name>, href, children }; prototype filters by roles, [auth] by permission through prepareMenu(menu, can) | web:src/constants/menu.ts:4-60, webauth:src/utils/menu.ts:5-20 |
| permission gating [auth] | page: withPermission(Page, key); element: <Can permission="x.update">; logic: usePermission().can(key); keys are the PermissionKey union in constants/permission-keys.ts | webauth:src/utils/permission.ts:4-10, webauth:src/components/Can/index.tsx:9-15, webauth:src/hooks/permission/index.ts:4-14 |
| list page A: master data | useCompactList({ entity, itemKey='code', baseFilter }) + <CompactList> left, <Form> right; paging/keyword/sort in router query p/q/s/f | web:src/services/compact_list/index.ts:14-90, web:src/pages/master_data/store/index.tsx:12-115 |
| list page B: config entities | <PageHeader title actions> + search Input (debounced) + <Table keyData columns data> + TableFooter; row link to /<area>/<name>/[id]; service hook keeps page/pageSize/keywords in state | webauth:src/pages/setting/templates/index.tsx:16-80, webauth:src/services/template/index.ts:6-54 |
| detail page | [id].tsx with id === 'create' for new; editor state in services/<name>/editor.ts | web:src/pages/setting/templates/[id].tsx, web:src/services/template/editor.ts |
| settings page | useSetting({ name }): GET record, PUT { rev, values }, refetch on conflict; secrets shown as •••• and sent only when typed | web:src/services/setting/index.ts:14-104 |
| forms | Form, FormItem, FormList, useForm, useWatch wrappers over rc-field-form; inputs in components/forms/*; never import rc-field-form directly | web:src/components/forms/Form/index.tsx |
| ui | components/ui/{Button,Table,Modal,Drawer,Tabs,Badge,Icon,PageHeader,CompactList,Calendar,ImagePreview}; folder <Name>/index.tsx + types.ts; tailwind theme classes, no hex | store patterns file |
| imports | @/ alias; lodash per method (lodash/get) |
10. Naming
| thing | rule as built | examples / where |
|---|---|---|
| module folder, file | snake_case, one folder per module | delivery_order_item/delivery_order_item.service.ts |
| class | PascalCase, suffix by kind (XService, XController, XModule, XInput) | |
| model token | _.snakeCase(Class.name) | delivery_order |
| collection | Mongoose plural of the token; explicit when it must differ | delivery_orders, _settings, activity_logs, kafka_logs, google_logs |
| fields | snake_case; refs embedded as { id, code, name } (_.pick) plus, on auth, a flat <x>_id for scoping | src/vehicle/vehicle.service.ts:25, auth:src/delivery_order/delivery_order.service.ts (+92) |
| enum name / keys / values | PascalCase name, snake keys; values lowercase in most enums, UPPER in DO and jobs (§11 D8) | src/common/enums/user.enum.ts:9-12, src/common/enums/delivery_order.enum.ts:1-3, src/jobs/jobs.constants.ts:1-3 |
| constants | UPPER_SNAKE in <m>.constants.ts | LOSSY_QUEUE = 'side_effect_lossy' (src/jobs/jobs.constants.ts:36) |
| permission key [auth] | <resource>.<suffix>; resource = @Resource (dotted for settings areas: setting.kafka, setting.jobs); suffix = verb (read/create/update/delete) or @Permission | auth:src/common/permission/permission.key.ts:19-20; full list → 02 §4 |
| setting record / key | ^[a-z][a-z0-9_]*$; read as <record>.<key>; log retention keys log.<log_key> | src/settings/settings.types.ts:5, 03 §8.1 |
| job target | kafka:<topic>, schedule:<code> | src/kafka/kafka.target.ts:9, src/schedules/schedules.constants.ts:16 |
| job code / Kafka event | snake event name (do_update_oms, do_consume) | src/kafka/kafka.constants.ts:17-22 |
| Kafka topic | dotted + kebab (lastmiles.oms.do-update, bigc.lastmiles.do-post) | src/kafka/kafka.constants.ts:17-22 |
| dedupe key / event key | colon-joined parts: <event>:<key>:<version>, eventKey('do', delivery_no, change) | src/delivery_order/delivery_order.service.ts:190, src/activity_log/event_key.ts:4 |
activity entity / action | entity = model token; action kebab <short>-<verb> (do-create, setting-change) | src/delivery_order/delivery_order.service.ts:115-117, src/activity_log/activity_log.service.ts:34-37 |
| Bull queue | snake (side_effect_lossy, attachment_sweep) | src/attachment/attachment.constants.ts:11 |
| Redis key | prefix with colon (kafka:status:, settings:changed, TOKEN_<token>) | src/kafka/kafka.constants.ts:5, src/settings/settings.types.ts:2; full list in 02/03/05 §3 |
| error code | lowercase snake (UPPER in attachment/storage, §11 D5) | |
| web translate key | <feature>_<field>, menu_<…>, common_<…> | web:src/utils/translate.ts:3-12 |
11. Pattern drift between modules
| # | drift | evidence | which to copy |
|---|---|---|---|
| D1 | Route path: singular (vehicle, store, plan, delivery_order, delivery_order_item, device, user, application) vs plural (users, roles, templates, export_configs, jobs, schedules, settings, attachments, activity_logs, permissions); nested (template/plan, type/vehicle); module ≠ path (field_catalog → export_fields); kebab (kafka/test-connection) | src/*/**.controller.ts @Controller blocks; user and users both exist on auth (auth:src/user/user.controller.ts:22-25, auth:src/user/user.admin.controller.ts:8-11) | plural snake for new resources (newest modules) — not decided in any doc |
| D2 | URL key: :code / :delivery_no / :item_id / :user_id vs :id | vehicle, plan, DO vs templates, export_configs, roles, jobs | business key for master data, :id for config rows |
| D3 | List response: SearchResultDto {rows,…}; jobs {data,total,page,limit} with lean _id; activity {rows,next_cursor}; roles/users/permissions {items}; settings raw array | src/jobs/jobs.controller.ts:31-41, src/activity_log/activity_log.query.ts:69-70, auth:src/role/role.controller.ts:14-17, src/settings/settings.service.ts:112-120 | SearchResultDto for paged lists. Mask/Hide only understand rows |
| D4 | Controller talks to the model with no service | src/jobs/jobs.controller.ts:27-73 | controller → service |
| D5 | Error codes: lowercase vs UPPER (NOT_FOUND, FILE_TOO_LARGE); not_found returned as 400 in 9 older call sites, 404 in 14 newer | src/vehicle/vehicle.service.ts:34, src/plan/plan.service.ts:125 vs src/attachment/attachment.service.ts:153, src/template/template.service.ts:40 | lowercase snake, 404 |
| D6 | Duplicate key: filter 500 duplicate_unique_key, template 400 duplicate_code, role 409 duplicate_code; helper lives in activity_log/event_key.ts, imported by role/user/application (feature → feature util) | §4.5 | catch in the service, 409; helper belongs in common/utils |
| D7 | Concurrency field: rev + rev_conflict (settings) vs revision + version_conflict (template, export_config); everything else last-write-wins | §4.6 | revision + version_conflict (2 of 3) |
| D8 | Enum values: lowercase (UserRole, PlanStatus, AttachmentStatus) vs UPPER (DeliveryOrderStatus, JobStatus, PlanStopType values); enums in jobs.constants.ts instead of an enum file | src/common/enums/*.ts, src/jobs/jobs.constants.ts:1-14 | lowercase values; enums in *.enum.ts |
| D9 | Module-level constants inside a service (guide: "never") | src/delivery_order/delivery_order.service.ts:24-29 | <m>.constants.ts |
| D10 | Auth decorators: prototype @Roles vs auth @Resource; prototype ignores class-level @Public | src/common/guards/auth.guard.ts:77, auth:src/common/permission/permission.registry.ts:64 | @Resource (after auth merges) |
| D11 | Background repeat: attachment sweep is a Bull repeatable, not a declared schedule | src/attachment/attachment.module.ts:26, src/attachment/attachment.sweep.processor.ts:21 (06 §7) | SchedulesService.declare for durable crons |
| D12 | ListQueryDto.keywoard vs SearchQueryDto.keywords | src/common/dto/list_query.dto.ts:6, src/common/dto/search_query.dto.ts:6 | keywords |
| D13 | Activity / history action naming: kebab (do-create, setting-change) vs UPPER (ASSIGN_STOP, MOVE_STOP in plan history events) | src/delivery_order/delivery_order.service.ts:117, src/plan/plan_operation.service.ts:132 | kebab |
| D14 | Audit fields created_by/updated_by only in template/export_config (string) and settings ({id,name}) | §4.1 | not decided |
| D15 | Web paths: kebab (delivery/delivery-order, setting/outbound-events, /api/storage-settings) vs snake (master_data/*, setting/export_configs, /api/export_configs) | web:src/pages/** | not decided |
| D16 | Web route error fallback: e.status || 500 vs bare e.status (crashes when the error has no status) | web:src/pages/api/settings/[code]/index.ts:14 vs web:src/pages/api/entity/[entity]/index.ts:15, web:src/pages/api/user/me.ts:11 | e.status || 500 |
| D17 | Two web list patterns (CompactList for master data, PageHeader+Table for config) | §9.2 | match the area |
| D18 | DEVELOPER_GUIDE.md is stale: src/setting/ + SettingInterceptor (now src/settings, no interceptor), "no .env.example" (one exists), roles admin/store_operator/driver only, MongoExceptionFilter "appropriate response" (it is 500) | /Users/thanosorn/Works/Software/tms/_docs/bigc-tms-core-v2/DEVELOPER_GUIDE.md §2, §3, §5.6-5.7 | code |
| D19 | Store patterns file says npm test is a stub; jest is configured | web:package.json test: jest, web:jest.config.js | code |
Risks found in the shared layer (not module drift)
| # | risk | where |
|---|---|---|
| R1 | Every SearchQueryDto endpoint passes a client-built Mongo filter straight to find(): any operator, including $where/$expr, any field, including hidden ones. The operator block-list exists only in the script runner | src/common/utils/filter.ts:12-44, src/common/base/base.service.ts:63; block-list src/script_runner/script_runner.constants.ts:21 |
| R2 | keywords is used as an unescaped regex (regex errors → 500, ReDoS) | src/common/base/base.service.ts:56 |
| R3 | No page default (skip(NaN)) and no page_size cap | src/common/base/base.service.ts:104-105 |
| R4 | MongoExceptionFilter throws inside the filter and ignores other Mongo codes; Mongoose ValidationError/CastError are not MongoError and become 500 | src/common/filters/mongo.filters.ts:5-16 |
| R5 | Health check probes nothing | src/app.service.ts:6-10 |
| R6 | CORS any origin, ~1000 MB body limit | src/main.ts:19-23 |
| R7 | ValidationPipe without whitelist | src/main.ts:45-50 |
| R8 | Hard delete only; no soft delete anywhere | src/common/base/base.service.ts:227-235 |
| R9 | [auth] direct this.model.* in a @Scoped service silently bypasses the store scope | §4.4 |
12. Checklist — a new module in phase N
Example name trip. Copy from vehicle (smallest CRUD) for the skeleton, and from delivery_order when the module writes history or emits events.
| # | step | copy from | registries / files touched |
|---|---|---|---|
| 1 | Create src/trip/. Put shared enums in src/common/enums/trip.enum.ts (lowercase values), constants in trip.constants.ts, types in trip.types.ts | src/common/enums/plan.enum.ts | — |
| 2 | trip.schema.ts: @Schema({ strict: true, timestamps: { createdAt: 'created_at', updatedAt: 'updated_at' } }) class Trip extends BaseSchema, snake fields, unique business key, TripSchema = BaseSchemaFactory.createForClass(Trip), TripSchema.index(...) after it; add revision: number if admins edit it concurrently | src/vehicle/vehicle.schema.ts, src/template/template.schema.ts:73 | — |
| 3 | trip.dto.ts: TripInput / TripUpdateInput (with revision) using class-validator | src/template/template.dto.ts:28-66 | — |
| 4 | trip.service.ts: @Injectable() class TripService extends BaseService<Trip> with @InjectModel(_.snakeCase(Trip.name)); findByCode → throwError('Trip not found', 'not_found', 404); guarded update { _id, revision } → 409 version_conflict; catch duplicate → 409 duplicate_code | src/vehicle/vehicle.service.ts, src/template/template.service.ts:38-57 | [auth] @Scoped({ store: 'store_id' }) + flat store_id field + index (02 §13 "store-scoped entity") |
| 5 | Writes with side effects: inTransaction(this.model.db, async (tx) => { create/update with { session: tx.session }; await this.activity.record(tx.session, {...}); await this.sideEffect.enqueue(..., tx) }) | src/delivery_order/delivery_order.service.ts:112-195 | ActivityLogModule, JobsModule imports |
| 6 | trip.controller.ts: @Controller({ path: 'trips', version: '1' }); GET / with SearchQueryDto + convertFilter; GET /:code, POST /, PUT /:code; @Profile('id') for the actor; @HideFields/@MaskFields on the class if needed | src/vehicle/vehicle.controller.ts, src/template/template.controller.ts | prototype: @Roles([...]); [auth] @Resource('trip') (+ @Permission for special actions) → regenerate keys: npm run permissions:types -- <out.ts> and copy into webauth:src/constants/permission-keys.ts + registry-keys.json |
| 7 | trip.module.ts: MongooseModule.forFeatureAsync([{ name: _.snakeCase(Trip.name), useFactory: () => TripSchema }]), imports, exports: [TripService] | src/vehicle/vehicle.module.ts | — |
| 8 | Script/template/export readable? Import ScriptEntitiesModule and register({ name: 'trip', service, controller: TripController }) in onModuleInit; skip it if the entity holds secrets | src/vehicle/vehicle.module.ts:29-37 | ScriptEntitiesRegistry (field catalog picks it up at bootstrap) |
| 9 | Settings? trip.settings.ts with defineSettingRecord/defineSetting; SettingsModule.forFeature([...]) in imports; read settings.get(REF) | src/google/google.settings.ts, src/google/google.module.ts:12 | settings registry; web settings page → 03 §13.1 |
| 10 | Own log collection? trip_log.schema.ts + LogModule.forFeature([TRIP_LOG_DEFINITION]) + @InjectLog | src/google/google_log.schema.ts:31-37 | log registry → 03 §13.2 |
| 11 | Kafka out: add the event to KAFKA_EVENTS, enqueue kafkaTargetName(topic) in the tx; Kafka in: trip.kafka_consumer.ts + registerConsumer | src/kafka/kafka.constants.ts:17-22, src/delivery_order/delivery_order.kafka_consumer.ts | job targets, Kafka consumers → 05 §13 |
| 12 | Durable background work: own target via JobTargetsRegistry.register in onModuleInit, or a cron via SchedulesService.declare | src/kafka/kafka.target.ts:60-77, src/schedules/schedules.service.ts:55-65 | 04 §13 |
| 13 | Add TripModule to src/app.module.ts imports | src/app.module.ts:39-72 | — |
| 14 | Specs beside the source: service rules with a fake model or Test.createTestingModule, condition ids in titles; run npx jest src/trip --maxWorkers=3 | src/jobs/side_effect.service.spec.ts, src/template/template.service.spec.ts | [auth] update auth:src/common/scope/__snapshots__/scope.spec.ts.snap when scoped |
| 15 | Web: apis/server/trip, pages/api/trips/... (e.status || 500, 405), apis/client/trip + types.ts, services/trip, pages/<area>/trip/index.tsx (+ [id].tsx), MENU entry, t() keys trip_* in utils/translate.ts; [auth] withPermission(Page, 'trip.read'), <Can> on buttons, permission on the menu item | webauth:src/pages/setting/templates/index.tsx, web:src/pages/api/settings/[code]/index.ts, web:src/services/template/index.ts | constants/menu.ts, constants/permission-keys.ts |
| 16 | Lint only the changed files (npx eslint <files>); nest build must exit 0 before any push | .eslintrc.js, .prettierrc | — |