Skip to content

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 filerepoworktreebranchshanotes
src/…bigc-tms-core-v2/Users/thanosorn/Works/Software/tms/wt-v2-prototype-coremain-v2-prototype3d70475afoundation simplify already applied (3dd1c5a0)
auth:src/…bigc-tms-core-v2/Users/thanosorn/Works/Software/tms/wt-v2-auth-corefeature/auth (not merged)9d9422cdiff base 3d70475a; adds permission registry, @Scoped, roles
web:src/…bigc-tms-store-v2/Users/thanosorn/Works/Software/tms/wt-v2-prototype-storemain-v2-prototype0851a3a
webauth:src/…bigc-tms-store-v2/Users/thanosorn/Works/Software/tms/wt-v2-auth-storefeature/auth (not merged)2d49976diff 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/) ​

pathwhat lives there
main.tsbootstrap: Fastify, prefix, versioning, global pipe/filter/interceptors, guard (prototype only) (src/main.ts:17-54)
app.module.tsroot 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 ​

fileholdsexample
<m>.module.ts@Module: MongooseModule.forFeatureAsync, imports of platform modules, registry calls in onModuleInitsrc/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 methodssrc/vehicle/vehicle.service.ts:13-59
<m>.controller.tsthin HTTP layer: parse, call service, returnsrc/vehicle/vehicle.controller.ts:19-56
<m>.dto.tsclass-validator input classes <Name>Input, query classes <Name>QueryDtosrc/vehicle/vehicle.dto.ts:4-14
<m>.constants.tsUPPER_SNAKE constants, lookup maps, queue names, collection/log keyssrc/jobs/jobs.constants.ts, src/attachment/attachment.constants.ts:11
<m>.types.tstype / interface onlysrc/kafka/kafka.types.ts, src/settings/settings.types.ts
<m>.enum.tsenums of one module (shared ones go to common/enums/)src/kafka/kafka.enum.ts
<m>.settings.tsdefineSettingRecord + defineSetting refs of the modulesrc/google/google.settings.ts:4-14
<m>_log.schema.tsa BaseLog subclass + its LogDefinitionsrc/google/google_log.schema.ts:10-37
<m>.registry.tsa 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.tsa job target that registers into JobTargetsRegistrysrc/kafka/kafka.target.ts:60-77
<m>.kafka_consumer.tsan inbound consumer that registers into KafkaServicesrc/delivery_order/delivery_order.kafka_consumer.ts:10-33
<name>.payload.tspure builders of an outbound message bodysrc/delivery_order/do-update.payload.ts
<m>.processor.ts / <lane>.worker.tsBull @Processor or a polling workersrc/attachment/attachment.sweep.processor.ts:21, src/jobs/fast_lane.worker.ts
<m>.query.tsread-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.tsjest spec beside the sourcesrc/jobs/side_effect.service.spec.ts
<m>.it.spec.tsintegration spec that needs real services, skipped without envsrc/kafka/kafka.consumer.it.spec.ts:11-14
<m>.fake-spec.tsin-memory fake shared by specs (not matched by testRegex)src/settings/settings.fake-spec.ts:4-22

2.3 Web (store-v2 src/) ​

pathwhat lives there
pages/<area>/<name>/index.tsx, pages/<area>/<name>/[id].tsxUI pages (pages router)
pages/api/<resource>/…Next API routes (BFF) that call core
apis/client/<resource>/{index,types}.tsbrowser → /api/* wrappers
apis/server/<resource>/index.tsNext server → API_GATEWAY wrappers
services/<feature>/{index,types}.tsahooks useRequest hooks: page state, toasts
stores/<name>/{index,types}.tszustand 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.tsjest + RTL

3. Module skeleton and registries ​

3.1 Declaring a module and wiring it ​

stepcodewhere
model token = _.snakeCase(Class.name), same string in forFeatureAsync and @InjectModelMongooseModule.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 itexplicit: _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 exportsimports: [VehicleTypeModule]src/vehicle/vehicle.module.ts:22
platform modules imported where usedJobsModule, ActivityLogModule, KafkaModule, ScriptEntitiesModulesrc/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 @Globalsrc/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.

registryowner / filehow a module registerswhen filledread byduplicate / unknown
SettingssettingsRegistry singleton, src/settings/settings.registry.ts:38-111SettingsModule.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
LogLogModule.forFeature(defs), src/log/log.module.ts:22-45LogModule.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 targetsJobTargetsRegistry, src/jobs/job_targets.registry.ts:19-52inject the registry, register({ name, lane, handler, lossy?, delivery_mode?, exclusive? }) in onModuleInit (src/kafka/kafka.target.ts:60-77)onModuleInitSideEffectService.enqueueMany (src/jobs/side_effect.service.ts:52) and workersduplicate → duplicate_target (:24); unknown at enqueue → unknown_target (:37), rolls back the caller's tx; detail → 04 §13.A
SchedulesSchedulesService.declare(), src/schedules/schedules.service.ts:55-65 (registers a long-lane exclusive target schedule:<code>)inject SchedulesService, declare({ code, cron, … }) in onModuleInitonModuleInit; row upserted in onApplicationBootstrap (:67-72)long laneduplicate → duplicate_schedule (:57). No module declares one at 3d70475a; detail → 04 §13.C
Kafka consumersKafkaService.registerConsumer(event, handler), src/kafka/kafka.service.ts:57in onModuleInit of a <m>.kafka_consumer.ts provider (src/delivery_order/delivery_order.kafka_consumer.ts:20-22)onModuleInitconsumer runnerdetail → 05 §13.B
Script entities (also feeds field catalog and templates)ScriptEntitiesRegistry, src/script_runner/script_entities.registry.ts:13-39import ScriptEntitiesModule, inject registry, register({ name, service, controller }) in onModuleInit (src/vehicle/vehicle.module.ts:35-37)onModuleInitlazily 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] PermissionsPermissionRegistry, auth:src/common/permission/permission.registry.ts:33-99nothing to call: put @Resource('<prefix>') on the controller class and optionally @Permission(suffix, label) / @LoggedIn() / @Public() on handlersonModuleInit scans every controller through DiscoveryService (:44-49)AuthGuard per request, GET /api/v1/permissionsdetail → 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 / behaviouras builtwhere
_idMongo ObjectId; removed from JSON output:30
idvirtual string of _id, in JSON (virtuals: true):7, :17
created_at, updated_atMongoose timestamps, snake_case (each schema repeats the option, e.g. src/vehicle/vehicle.schema.ts:5-11):5
__vwritten by Mongoose, hidden from JSON (versionKey: false is a toJSON option only); not used for concurrency:18
_translatesMixed, reserved for per-field translations; nothing writes it:9-10
Decimal128converted to a number rounded to 2 dp in JSON (10 levels deep):20-31
created_by / updated_bynot 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: trueper schema (every domain schema sets it)src/vehicle/vehicle.schema.ts:6
Log rowsextend BaseLog, not BaseSchema directly; no timestamps, at + expire_at TTL03 §3.2

4.2 BaseService<T> (src/common/base/base.service.ts) ​

methodbehaviourwhere
constructorthis.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, insertManyMongoose passthrough:237-251
return shapeevery 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)typerule
keywordsstringregex over every string field + extra searchFields; not escaped (base.service.ts:56)
filterqs-encoded stringcontroller 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
sorta,-b- = descending (base.service.ts:67-80)
fieldsstringMongoose select
pagepositive intno default: missing page gives skip(NaN) (base.service.ts:105)
page_sizepositive intdefault 10 (:104), no maximum
responseSearchResultDto { 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 ​

piecebehaviourwhere
@Scoped({ <scope>: '<field>' }) on the service classmarks the entity; BaseService reads it into this._scopeauth: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_foundauth:src/common/base/base.service.ts:67-77, :183
writescreate/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 checkedauth:src/common/scope/scope.ts:59-92
aggregateprepends a cast $matchauth:src/common/base/base.service.ts:254-258
unrestrictednon-HTTP channels, system, access.all, API keysauth:src/common/scope/scope.ts:23-24
asSystem(fn)run unfiltered as the system actor, same traceauth:src/common/scope/scope.ts:95-98
direct this.model.* calls bypass itmust wrap with withScope/assignScope by hand, as DO update doesauth: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 ​

layerbehaviourwhere
global MongoExceptionFiltercatches 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
helperisDuplicateKeyError(error) (code 11000 or E11000 in message) lives in activity_log/event_key.ts and is imported by role/user/applicationsrc/activity_log/event_key.ts:10
expected duplicates are caught in the servicetemplate → 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 idempotencyunique event_key on activity_logs → ActivityAlreadyApplied; unique dedupe_key on jobssrc/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 } }):

modulefieldconflict errorwhere
settingsrev409 rev_conflict, rev in bodysrc/settings/settings.service.ts:221-241, :358-360
templaterevision409 version_conflictsrc/template/template.service.ts:38-57
export_configrevision409 version_conflictsrc/export_config/export_config.service.ts:36-45
status transitionsguard on current status in the filter409 invalid_statussrc/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 ​

topicas builtwhere
prefixapi, except GET /health_checksrc/main.ts:32-34
versioningURI; 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 keybusiness 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
validationglobal 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
DTOclass-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 / CORSFastify 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 envelopenone: the controller returns the entity JSON, SearchResultDto, or a module-specific object (§11 D3)
status codeNest defaults: POST 201, others 200; override with @HttpCode(200) (src/template/template.controller.ts:38-39)

5.3 Error envelope and codes ​

sourcestatusbody
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)
ValidationPipe400Nest default { statusCode, message: string[], error: 'Bad Request' }, no code
guard returns false (prototype)403Nest 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] guard401 unauthorized, 403 permission_denied (additional_info.key), 403 store_forbiddenauth:src/common/guards/auth.guard.ts:30,105,112
duplicate key (unhandled)500 duplicate_unique_keysrc/common/filters/mongo.filters.ts:9-14
anything else thrown500 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 ​

decoratorleveleffectwhere
@MaskFields([...]) / @IgnoreMaskField()class / handlerMaskInterceptor 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 / handlerHideInterceptor omits fields, same three shapessrc/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()handlerprototype: 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 onlykey 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?)handleroverrides the verb suffix (GET read, POST create, PUT/PATCH update, DELETE delete)auth:src/common/permission/permission.key.ts:5-20
[auth] @LoggedIn()handler / classany valid token, no keyauth: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).

envdefaulteffectread at
PORT3000HTTP portsrc/main.ts:15
MONGODB_URI—Mongoose root (replica set needed for transactions)src/app.module.ts:44
QUEUE_REDIS_URL—Bull rootsrc/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 adminsrc/user/user.service.ts:81
DEFAULT_APP_KEY, DEFAULT_SECRET_KEYrandomdefault applicationsrc/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_WORKERSonoff stops both lanes and schedule ticks on this podsrc/jobs/jobs.constants.ts:40
KAFKA_BROKERS, KAFKA_CLIENT_ID, KAFKA_GROUP_PREFIX, KAFKA_TARGET_TOPICSsee 05 §8
SETTINGS_RELOAD_INTERVAL_MS60000full settings reloadsrc/settings/settings.types.ts:4
S3_ENDPOINTunsetLocalStack onlysrc/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/v1web:src/apis/server/request.ts:20
web SYSTEM_APP_KEY, SYSTEM_SECRET_KEY—login applicationweb:src/apis/server/auth/index.ts:5-6
web COOKIE_SAME_SITE, COOKIE_SECURE, COOKIE_HTTP_ONLYlax / false / falsetoken cookieweb:src/pages/api/auth/login.ts:13-15

7.2 Boot order ​

factwhere
settings registry is complete before any hooksrc/settings/settings.registry.ts:110
SettingsService bootsrc/settings/settings.service.ts:56-67; get() before it finishes returns the code default (:79-80)
system user id set in onModuleInitsrc/user/user.service.ts:28-30
field catalog reads the script registry in onApplicationBootstrapsrc/field_catalog/field_catalog.service.ts:77
lanes / schedules start in onApplicationBootstrapsrc/jobs/fast_lane.worker.ts:143, src/jobs/long_lane.worker.ts:49, src/schedules/schedules.service.ts:67-72
Kafka bootsrc/kafka/kafka.service.ts:61-68
shutdown hookssrc/main.ts:52; ordering → 04 §9, 05 §9

7.3 Health ​

endpointauthresponsechecks
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/statussetting.kafka.read on authper-pod Kafka status05 §4
web GET /api/health_checknoneNext routeweb:src/pages/api/health_check/index.ts

8. Testing conventions ​

topiccoreweb
runnerjest 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 styleconstruct 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 / Redisno 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 namescondition ids in titles (C1 …, C3 …), e.g. src/common/context/context.spec.ts:30-139
runnpx 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.jsonnpx jest --maxWorkers=3
snapshotsauth: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 ​

layerconventionwhere
pagepages/<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
layoutcomponents/layouts/index.tsx picks Login or Main from useLayout(); Main has Header, Menu, Submenuweb:src/components/layouts/index.tsx:7-28
_appSSR 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 failweb:src/pages/_app.tsx:112-136; webauth:src/pages/_app.tsx:109,126-185
service hookahooks useRequest (auto get + manual save), toast.success(t(...)) / toast.error(e.message); page stays presentationalweb:src/services/setting/index.ts:49-104
client apirequest(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 routeexport 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 fallbackweb:src/pages/api/settings/[code]/index.ts:5-30
server apirequest(path, method, body, getHeadersFromRequest(req)) → join(API_GATEWAY, path); throws { status, error: message, code, data }web:src/apis/server/request.ts:6-49
headersAuthorization: Bearer <token cookie>, X-Client-IP, User-Agent, client-ip; [auth] X-Store-Id from the store cookieweb: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 nameweb:src/apis/server/entity/index.ts:4-27, web:src/enums/entity.ts:1-11

9.2 State, i18n, permission, components ​

topicconventionwhere
storeszustand create<Props>(): profile ([auth] adds all, permissions: Set, selectedStore, can), setting (public settings), themeweb:src/stores/profile/index.ts:4-7, webauth:src/stores/profile/index.ts:4-16
i18nt(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 translatesweb:src/utils/translate.ts:3,347-352, web:src/pages/_app.tsx:41-43
menuMENU 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.tswebauth: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 datauseCompactList({ entity, itemKey='code', baseFilter }) + <CompactList> left, <Form> right; paging/keyword/sort in router query p/q/s/fweb: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 statewebauth: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.tsweb:src/pages/setting/templates/[id].tsx, web:src/services/template/editor.ts
settings pageuseSetting({ name }): GET record, PUT { rev, values }, refetch on conflict; secrets shown as •••• and sent only when typedweb:src/services/setting/index.ts:14-104
formsForm, FormItem, FormList, useForm, useWatch wrappers over rc-field-form; inputs in components/forms/*; never import rc-field-form directlyweb:src/components/forms/Form/index.tsx
uicomponents/ui/{Button,Table,Modal,Drawer,Tabs,Badge,Icon,PageHeader,CompactList,Calendar,ImagePreview}; folder <Name>/index.tsx + types.ts; tailwind theme classes, no hexstore patterns file
imports@/ alias; lodash per method (lodash/get)

10. Naming ​

thingrule as builtexamples / where
module folder, filesnake_case, one folder per moduledelivery_order_item/delivery_order_item.service.ts
classPascalCase, suffix by kind (XService, XController, XModule, XInput)
model token_.snakeCase(Class.name)delivery_order
collectionMongoose plural of the token; explicit when it must differdelivery_orders, _settings, activity_logs, kafka_logs, google_logs
fieldssnake_case; refs embedded as { id, code, name } (_.pick) plus, on auth, a flat <x>_id for scopingsrc/vehicle/vehicle.service.ts:25, auth:src/delivery_order/delivery_order.service.ts (+92)
enum name / keys / valuesPascalCase 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
constantsUPPER_SNAKE in <m>.constants.tsLOSSY_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 @Permissionauth: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 targetkafka:<topic>, schedule:<code>src/kafka/kafka.target.ts:9, src/schedules/schedules.constants.ts:16
job code / Kafka eventsnake event name (do_update_oms, do_consume)src/kafka/kafka.constants.ts:17-22
Kafka topicdotted + kebab (lastmiles.oms.do-update, bigc.lastmiles.do-post)src/kafka/kafka.constants.ts:17-22
dedupe key / event keycolon-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 / actionentity = 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 queuesnake (side_effect_lossy, attachment_sweep)src/attachment/attachment.constants.ts:11
Redis keyprefix 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 codelowercase 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 ​

#driftevidencewhich to copy
D1Route 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
D2URL key: :code / :delivery_no / :item_id / :user_id vs :idvehicle, plan, DO vs templates, export_configs, roles, jobsbusiness key for master data, :id for config rows
D3List response: SearchResultDto {rows,…}; jobs {data,total,page,limit} with lean _id; activity {rows,next_cursor}; roles/users/permissions {items}; settings raw arraysrc/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-120SearchResultDto for paged lists. Mask/Hide only understand rows
D4Controller talks to the model with no servicesrc/jobs/jobs.controller.ts:27-73controller → service
D5Error codes: lowercase vs UPPER (NOT_FOUND, FILE_TOO_LARGE); not_found returned as 400 in 9 older call sites, 404 in 14 newersrc/vehicle/vehicle.service.ts:34, src/plan/plan.service.ts:125 vs src/attachment/attachment.service.ts:153, src/template/template.service.ts:40lowercase snake, 404
D6Duplicate 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.5catch in the service, 409; helper belongs in common/utils
D7Concurrency field: rev + rev_conflict (settings) vs revision + version_conflict (template, export_config); everything else last-write-wins§4.6revision + version_conflict (2 of 3)
D8Enum values: lowercase (UserRole, PlanStatus, AttachmentStatus) vs UPPER (DeliveryOrderStatus, JobStatus, PlanStopType values); enums in jobs.constants.ts instead of an enum filesrc/common/enums/*.ts, src/jobs/jobs.constants.ts:1-14lowercase values; enums in *.enum.ts
D9Module-level constants inside a service (guide: "never")src/delivery_order/delivery_order.service.ts:24-29<m>.constants.ts
D10Auth decorators: prototype @Roles vs auth @Resource; prototype ignores class-level @Publicsrc/common/guards/auth.guard.ts:77, auth:src/common/permission/permission.registry.ts:64@Resource (after auth merges)
D11Background repeat: attachment sweep is a Bull repeatable, not a declared schedulesrc/attachment/attachment.module.ts:26, src/attachment/attachment.sweep.processor.ts:21 (06 §7)SchedulesService.declare for durable crons
D12ListQueryDto.keywoard vs SearchQueryDto.keywordssrc/common/dto/list_query.dto.ts:6, src/common/dto/search_query.dto.ts:6keywords
D13Activity / 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:132kebab
D14Audit fields created_by/updated_by only in template/export_config (string) and settings ({id,name})§4.1not decided
D15Web 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
D16Web 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:11e.status || 500
D17Two web list patterns (CompactList for master data, PageHeader+Table for config)§9.2match the area
D18DEVELOPER_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.7code
D19Store patterns file says npm test is a stub; jest is configuredweb:package.json test: jest, web:jest.config.jscode

Risks found in the shared layer (not module drift) ​

#riskwhere
R1Every 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 runnersrc/common/utils/filter.ts:12-44, src/common/base/base.service.ts:63; block-list src/script_runner/script_runner.constants.ts:21
R2keywords is used as an unescaped regex (regex errors → 500, ReDoS)src/common/base/base.service.ts:56
R3No page default (skip(NaN)) and no page_size capsrc/common/base/base.service.ts:104-105
R4MongoExceptionFilter throws inside the filter and ignores other Mongo codes; Mongoose ValidationError/CastError are not MongoError and become 500src/common/filters/mongo.filters.ts:5-16
R5Health check probes nothingsrc/app.service.ts:6-10
R6CORS any origin, ~1000 MB body limitsrc/main.ts:19-23
R7ValidationPipe without whitelistsrc/main.ts:45-50
R8Hard delete only; no soft delete anywheresrc/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.

#stepcopy fromregistries / files touched
1Create src/trip/. Put shared enums in src/common/enums/trip.enum.ts (lowercase values), constants in trip.constants.ts, types in trip.types.tssrc/common/enums/plan.enum.ts—
2trip.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 concurrentlysrc/vehicle/vehicle.schema.ts, src/template/template.schema.ts:73—
3trip.dto.ts: TripInput / TripUpdateInput (with revision) using class-validatorsrc/template/template.dto.ts:28-66—
4trip.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_codesrc/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")
5Writes 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-195ActivityLogModule, JobsModule imports
6trip.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 neededsrc/vehicle/vehicle.controller.ts, src/template/template.controller.tsprototype: @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
7trip.module.ts: MongooseModule.forFeatureAsync([{ name: _.snakeCase(Trip.name), useFactory: () => TripSchema }]), imports, exports: [TripService]src/vehicle/vehicle.module.ts—
8Script/template/export readable? Import ScriptEntitiesModule and register({ name: 'trip', service, controller: TripController }) in onModuleInit; skip it if the entity holds secretssrc/vehicle/vehicle.module.ts:29-37ScriptEntitiesRegistry (field catalog picks it up at bootstrap)
9Settings? trip.settings.ts with defineSettingRecord/defineSetting; SettingsModule.forFeature([...]) in imports; read settings.get(REF)src/google/google.settings.ts, src/google/google.module.ts:12settings registry; web settings page → 03 §13.1
10Own log collection? trip_log.schema.ts + LogModule.forFeature([TRIP_LOG_DEFINITION]) + @InjectLogsrc/google/google_log.schema.ts:31-37log registry → 03 §13.2
11Kafka out: add the event to KAFKA_EVENTS, enqueue kafkaTargetName(topic) in the tx; Kafka in: trip.kafka_consumer.ts + registerConsumersrc/kafka/kafka.constants.ts:17-22, src/delivery_order/delivery_order.kafka_consumer.tsjob targets, Kafka consumers → 05 §13
12Durable background work: own target via JobTargetsRegistry.register in onModuleInit, or a cron via SchedulesService.declaresrc/kafka/kafka.target.ts:60-77, src/schedules/schedules.service.ts:55-6504 §13
13Add TripModule to src/app.module.ts importssrc/app.module.ts:39-72—
14Specs beside the source: service rules with a fake model or Test.createTestingModule, condition ids in titles; run npx jest src/trip --maxWorkers=3src/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
15Web: 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 itemwebauth:src/pages/setting/templates/index.tsx, web:src/pages/api/settings/[code]/index.ts, web:src/services/template/index.tsconstants/menu.ts, constants/permission-keys.ts
16Lint only the changed files (npx eslint <files>); nest build must exit 0 before any push.eslintrc.js, .prettierrc—