23 Commits
Author SHA1 Message Date
shancheas a6a06ae1de Add MkDocs configuration and documentation rules for TrackGo
- Created a new `mkdocs.yml` file to define the structure and navigation for technical documentation.
- Updated `doc-updater.md` to clarify the role of the documentation specialist and the split between technical and user documentation.
- Introduced new rules in `documentation.mdc` and `technical-docs.mdc` to guide the creation of documentation, emphasizing the separation of technical and user content.
- Added a `readme.md` in the `docs` directory to provide instructions for using MkDocs with Backstage.
2026-09-04 15:54:39 +07:00
shancheas 7253dece8e Refactor code formatting for improved readability
- Reformatted import statements and object properties across multiple files to enhance code clarity and maintainability.
- Ensured consistent indentation and line breaks in test specifications and service implementations, improving overall code structure.
2026-09-02 08:14:39 +07:00
shancheas 82e4a0cbf0 Enhance check-in verification to ensure integer distance values
- Added a new function, `roundedDistanceMeters`, to round the calculated distance for NFC and GPS methods.
- Updated the `verifyTargetCheckIn` function to use the new rounding logic, ensuring distance values are integers.
- Added unit tests to verify that distance values returned for NFC check-ins are whole numbers, improving data consistency.
2026-09-01 23:48:12 +07:00
shancheas ec5f012d6f Add SKIP_GPS_VALIDATION feature for attendance and visit services
- Introduced SKIP_GPS_VALIDATION environment variable to control GPS validation during check-ins and check-outs.
- Updated loadEnv function to parse SKIP_GPS_VALIDATION and enforce its rules based on NODE_ENV.
- Enhanced attendance and visit services to utilize the new configuration, allowing GPS validation to be skipped in development environments.
- Added unit tests to verify the behavior of SKIP_GPS_VALIDATION in various scenarios, ensuring proper handling in both production and development contexts.
- Updated check-in verification logic to respect the SKIP_GPS_VALIDATION option, improving flexibility in location checks.
2026-09-01 22:01:25 +07:00
shancheas 4b48dbdf3c Add timeline tracking features with database schema updates
- Introduced new tables `timeline_footprints` and `timeline_activities` to manage employee location data and activity records.
- Updated `company_settings` to include `gps_interval_seconds` and `checkout_warning_radius_meters` for enhanced tracking configuration.
- Implemented foreign key constraints to ensure data integrity between new tables and existing `employees`, `customers`, and `visits` tables.
- Created services and controllers for handling timeline activities and footprints, including ingestion and retrieval of data.
- Enhanced DTOs and validation logic to support new fields and ensure correct data formats in API requests.
- Added unit and integration tests to validate the new functionalities and ensure proper handling of timeline records.
- Created migration scripts to apply the necessary database schema changes for the new features.
2026-09-01 20:36:47 +07:00
shancheas 9428a983f5 Refactor PlansRepository to optimize data loading and enhance error handling
- Replaced the previous hydration logic with a new method, `loadChildrenForRows`, to efficiently load related data for plans in a single query.
- Implemented a `groupByPlanId` utility to organize related entities by plan ID, improving data retrieval performance.
- Updated error handling in the `getPlan` method to throw a `NotFoundException` if a plan is not found.
- Modified end-to-end tests to validate the new structure of the response, ensuring that related destinations are correctly included in the API output.
2026-09-01 19:25:19 +07:00
shancheas 6b3ddfcff9 Add date and createdBy filters to sales payments functionality
- Introduced new filters `date` and `createdBy` in the `ListSalesPaymentsFilters` and `ListSalesPaymentsQuery` types to enhance querying capabilities.
- Updated the `SalesPaymentsRepository` to handle filtering based on the new fields.
- Enhanced the `SalesPaymentsService` to process the new filters and ensure proper date handling.
- Added unit tests to validate the integration of the new filters in the service layer.
- Updated DTOs to include validation for the new fields, ensuring correct data formats in API requests.
2026-09-01 15:29:07 +07:00
shancheas 51db4f4a4d Refactor privilege management to support hierarchical privilege keys
- Updated privilege key structure to use a 3- or 4-part dotted hierarchy (e.g., `GROUP.PARENT.MODULE`).
- Modified the `RequirePrivilege` decorator to accept multiple keys, allowing for OR logic in privilege checks.
- Enhanced `PrivilegesGuard` to validate against multiple privilege keys, improving access control logic.
- Created migration scripts to update existing privilege keys in the database to the new format.
- Updated related services, controllers, and tests to accommodate the new privilege key structure and validation logic.
2026-09-01 13:14:58 +07:00
shancheas 365a37b8d2 Add attendance and visit management features with database schema updates
- Introduced new `attendances` and `visits` tables to manage employee attendance and customer visits, including relevant fields for check-in and check-out details.
- Updated `company_settings` to include a `check_in_radius_meters` column for attendance validation.
- Implemented foreign key constraints to ensure data integrity between `attendances`, `visits`, `employees`, `branches`, and other related entities.
- Created new services and controllers for handling attendance and visit operations, including check-in, check-out, and bulk actions.
- Enhanced DTOs for attendance and visit data transfer, including validation for input data.
- Added unit and integration tests to validate the new functionalities and ensure proper handling of attendance and visit records.
- Created migration scripts to apply the necessary database schema changes for the new features.
2026-09-01 12:27:51 +07:00
shancheas 0e73d14381 Add address fields to sales invoices and implement related validations
- Introduced new columns `address`, `latitude`, and `longitude` in the `sales_invoices` table to store location details.
- Updated the `SalesInvoicesService` to handle the new fields, including validation for address format and geographical coordinates.
- Enhanced the `SalesInvoiceDto` and related data transfer objects to include the new fields for API requests and responses.
- Added unit and e2e tests to ensure proper handling of the new fields and validate their integration within the sales invoice workflow.
- Created a new migration script to apply the database schema changes for the sales invoices.
2026-09-01 09:50:19 +07:00
shancheas 23028abd48 Implement foreign key violation handling in EmployeesRepository delete methods
- Enhanced the `delete` and `bulkDelete` methods in `EmployeesRepository` to handle foreign key violations by throwing a `ConflictException` with a descriptive message.
- Added unit tests to verify that foreign key violations are correctly mapped to `ConflictException` and that unknown errors are rethrown as expected.
- Refactored error handling in the `delete` methods to improve clarity and maintainability.
2026-09-01 08:57:31 +07:00
shancheas 2955b974d2 Add reporting features with new report engine and bookmark management
- Introduced a comprehensive report engine for generating and managing reports, including sales and logistics reports.
- Added new API endpoints for retrieving report configurations, data, and metadata, ensuring secure access with privilege checks.
- Implemented a report bookmarks system to allow users to save and manage report filters and configurations.
- Created database migrations for the `report_bookmarks` table and updated the schema to support new report functionalities.
- Developed services and controllers for handling report queries and bookmarks, including CRUD operations for bookmarks.
- Enhanced API documentation to reflect the new reporting features and endpoints.
- Added unit and integration tests to validate the new functionalities and ensure data integrity across report operations.
2026-09-01 08:45:52 +07:00
shancheas 5579cf6566 Add payable filter to sales invoices and enhance invoice status handling
- Introduced a new `payable` filter in the `ListSalesInvoicesFilters` and `ListSalesInvoicesQuery` types to allow querying of invoices based on their payable status.
- Updated the `SalesInvoicesRepository` to incorporate logic for filtering invoices that are payable, checking both status and balance.
- Enhanced the `SalesInvoicesService` to support the new `payable` filter in query handling.
- Modified the `SalesInvoiceDto` to include the `payable` property for better API response representation.
- Added unit tests to validate the new filter functionality and ensure proper handling of invoice statuses during updates.
- Updated e2e tests to cover scenarios involving the new payable filter and status transitions for invoices.
2026-08-31 15:15:06 +07:00
shancheas afed5ff0f5 Enhance sales document flow with new service and related functionalities
- Introduced `SalesDocumentFlowService` to manage the lifecycle of sales documents, including packing slips and invoices.
- Implemented methods for processing orders, generating invoices, and handling packing slips based on order status.
- Updated `SalesOrdersService`, `PackingSlipsService`, and `SalesInvoicesService` to integrate with the new document flow service.
- Added new methods for marking drafts processed and checking if invoices are on sales plans.
- Enhanced existing services and repositories to support new functionalities, including status transitions and related document management.
- Updated DTOs to include new fields for packing slip and invoice IDs in sales order responses.
- Added unit tests for the new service and updated existing tests to cover new functionalities.
2026-08-31 09:35:18 +07:00
shancheas 627aeac4a0 Add API documentation for TrackGo HTTP API and enhance employee management features
- Created a new `api.md` file detailing the TrackGo HTTP API, including authentication, user management, and employee operations.
- Updated `Employee` type to simplify user relation handling by replacing `UserRelation` with a more concise structure.
- Enhanced filtering capabilities in employee queries to support an array of positions.
- Refactored employee-related services and repositories to accommodate the new position filtering logic.
- Added unit and e2e tests to validate the new API documentation and employee management functionalities.
2026-08-27 15:07:20 +07:00
shancheas 4c45a4371e Enhance pagination and ordering capabilities in API responses
- Updated pagination-response and read-write-controllers documentation to include `orderBy` and `orderType` parameters for sorting results.
- Introduced new `order-clause` module to handle ordering logic, including validation for order types and columns.
- Enhanced `PaginationQueryDto` to support ordering fields in API requests.
- Updated various repository and service classes to implement ordering in database queries.
- Added unit tests for new ordering functionality and ensured existing tests cover the updated behavior.
- Refactored related DTOs to include user and code relations for better data representation in responses.
2026-08-27 13:09:41 +07:00
shancheas 790725e227 Enhance employee and user management with linked user functionality
- Introduced `EmployeeUserWrite` type to manage user details associated with employees.
- Updated `EmployeesService` and `EmployeesRepository` to support user assignment and retrieval by user ID.
- Enhanced DTOs to include user information for employee creation and updates.
- Implemented validation to ensure proper handling of user data during employee operations.
- Added unit and e2e tests to validate the new functionality and ensure data integrity in user-employee relationships.
- Modified existing controllers to accommodate the new user linkage features in employee management.
2026-08-27 12:22:25 +07:00
shancheas 8a61c94078 Add user and employee management enhancements with database schema updates
- Introduced new columns `status`, `created_by`, and `updated_by` in the `users` table to track user status and ownership.
- Updated the `employees` table to include a foreign key reference to the `users` table via `user_id`.
- Created migration script `0012_users_primary.sql` to apply these changes to the database schema.
- Enhanced the `EmployeesService` and `EmployeesRepository` to support user assignments and related data retrieval.
- Updated DTOs and service methods to reflect the new user and employee relationships.
- Added unit tests to validate the new functionality and ensure data integrity.
- Modified existing controllers to accommodate the new fields and relationships in user and employee management.
2026-08-26 15:29:18 +07:00
shancheas f635ebeda0 Enhance branch management with foreign key relation handling
- Updated `BranchesModule` to include foreign key relations in list and write responses, ensuring they are represented as nested objects using `pickRelation`.
- Introduced new `relation-response.mdc` file to define guidelines for embedding foreign key relations.
- Modified `BranchesRepository` to support fetching related `division`, `createdByUser`, and `updatedByUser` data.
- Updated DTOs and service methods to reflect changes in response structure, removing direct foreign key IDs.
- Added unit tests to validate the new relation handling in branches service and repository.
- Enhanced e2e tests to verify the correct structure of branch responses with nested relations.
2026-08-26 13:44:13 +07:00
shancheas c9f9b31abf Add field management module with database schema and validation
- Introduced `FieldModule` to manage cycles and plans, including read and write controllers.
- Created database migrations for `company_settings`, `cycles`, `cycle_weekdays`, `cycle_destinations`, `plans`, `plan_destinations`, `plan_invoices`, and `plan_packing_slips` tables, including constraints and unique indexes.
- Developed service and repository layers for handling cycle and plan data operations.
- Added unit tests for the cycles and plans services, repositories, and controllers to ensure functionality and correctness.
- Updated application module to include the new `FieldModule` for better organization.
2026-08-25 18:20:19 +07:00
shancheas 34d4f2c120 Add products and sales management modules with database schema and validation
- Introduced `ProductsModule` to manage product data, including read and write controllers.
- Created database migrations for the `products`, `sales_requests`, `sales_orders`, `sales_invoices`, and related tables, including constraints and unique indexes.
- Implemented validation for product fields such as code, name, unit, and brand with corresponding utility functions.
- Developed service and repository layers for handling product and sales data operations.
- Added unit tests for the products and sales services, repositories, and controllers to ensure functionality and correctness.
- Updated application module to include the new `ProductsModule` and related sales modules for better organization.
2026-08-25 09:54:16 +07:00
shancheas ddbe8a9ef8 Add employees management module with database schema and validation
- Introduced `EmployeesModule` to manage employee data, including read and write controllers.
- Created database migrations for the `employees` table, including constraints and unique indexes.
- Implemented validation for employee fields such as name, code, and position with corresponding utility functions.
- Developed service and repository layers for handling employee data operations.
- Added unit tests for the employees service, repository, and controllers to ensure functionality and correctness.
- Updated application module to include the new `EmployeesModule` for better organization.
2026-08-24 15:15:52 +07:00
shancheas cdcc508947 Add customers management module with database schema and validation
- Introduced `CustomersModule` to manage customer data, including read and write controllers.
- Created database migrations for the `customers` and `customer_contacts` tables, including constraints and unique indexes.
- Implemented validation for customer fields such as name, code, and address with corresponding utility functions.
- Developed service and repository layers for handling customer data operations.
- Added unit tests for the customers service, repository, and controllers to ensure functionality and correctness.
- Updated application module to include the new `CustomersModule` for better organization.
2026-08-24 14:23:20 +07:00
322 changed files with 40716 additions and 842 deletions
+37 -423
View File
@@ -1,443 +1,57 @@
---
name: doc-updater
description: Documentation and codemap specialist. Use PROACTIVELY for updating codemaps and documentation. Runs /update-codemaps and /update-docs, generates docs/CODEMAPS/*, updates READMEs and guides.
description: Documentation specialist. Use PROACTIVELY to keep MkDocs technical docs and READMEs aligned with the codebase. Technical docs live in docs/ + mkdocs.yml; user docs live in sibling trackgo-fe/apps/docs-dev.
tools: Read, Write, Edit, Bash, Grep, Glob
model: opus
---
# Documentation & Codemap Specialist
# Documentation Updater
You are a documentation specialist focused on keeping codemaps and documentation current with the codebase. Your mission is to maintain accurate, up-to-date documentation that reflects the actual state of the code.
Keep technical documentation current with the TrackGo codebase. Follow `.cursor/rules/documentation.mdc` and `.cursor/rules/technical-docs.mdc`.
## Core Responsibilities
## Documentation split
1. **Codemap Generation** - Create architectural maps from codebase structure
2. **Documentation Updates** - Refresh READMEs and guides from code
3. **AST Analysis** - Use TypeScript compiler API to understand structure
4. **Dependency Mapping** - Track imports/exports across modules
5. **Documentation Quality** - Ensure docs match reality
| Kind | Location | Audience |
| --- | --- | --- |
| Technical | `docs/` + `mkdocs.yml` (this repo) | Developers / engineers |
| User | sibling `trackgo-fe/apps/docs-dev/` | Customers / operators |
## Tools at Your Disposal
Do **not** put customer how-tos in MkDocs. Do **not** treat `docs/CODEMAPS/*` as the default target unless those files already exist and the user asks for them.
### Analysis Tools
- **ts-morph** - TypeScript AST analysis and manipulation
- **TypeScript Compiler API** - Deep code structure analysis
- **madge** - Dependency graph visualization
- **jsdoc-to-markdown** - Generate docs from JSDoc comments
Technical coverage under `docs/`:
### Analysis Commands
```bash
# Analyze TypeScript project structure
npx ts-morph
# Generate dependency graph
npx madge --image graph.svg src/
# Extract JSDoc comments
npx jsdoc2md src/**/*.ts
```text
docs/
backend/ # NestJS API — this repo
frontend-web/ # trackgo-fe/apps/web
frontend-landing/ # trackgo-fe/apps/landing
mobile/ # trackgo_mobile
api.md # HTTP API reference (keep)
report-*.md # report docs (keep)
```
## Codemap Generation Workflow
## Source of truth
### 1. Repository Structure Analysis
```
a) Identify all workspaces/packages
b) Map directory structure
c) Find entry points (apps/*, packages/*, services/*)
d) Detect framework patterns (NestJS, Node.js, etc.)
```
1. This repo: `src/app.module.ts`, `src/modules/`, `docs/api.md`, `package.json`, `.env` examples
2. Sibling `trackgo-fe`: `apps/web` menus/modules, `apps/landing`
3. Sibling `trackgo_mobile`: `lib/config/router.dart`, `lib/ui/features/`, `pubspec.yaml`
### 2. Module Analysis
```
For each module:
- Extract exports (public API)
- Map imports (dependencies)
- Identify routes (API routes, pages)
- Find database models (Drizzle schema)
- Locate queue/worker modules
```
If a product brief disagrees with code, follow the code or label **Coming soon**. No emoji. Diagrams: PlantUML fenced blocks, not Mermaid.
### 3. Generate Codemaps
```
Structure:
docs/CODEMAPS/
├── INDEX.md # Overview of all areas
├── frontend.md # Frontend structure
├── backend.md # Backend/API structure
├── database.md # Database schema
├── integrations.md # External services
└── workers.md # Background jobs
```
## Workflow
### 4. Codemap Format
```markdown
# [Area] Codemap
1. Discover modules from `src/app.module.ts` (Auth, Users, Privileges, Configuration, Sales, Field, Reports)
2. Cross-read sibling FE/mobile for frontend-web, frontend-landing, and mobile sections
3. Update markdown under `docs/<area>/` and register pages in `mkdocs.yml` `nav`
4. When touching `mkdocs.yml`, set `site_name` to TrackGo if it still says something else (e.g. Concourse CI)
5. Keep existing `docs/api.md` and report pages accurate when APIs change
6. Preview with the Docker TechDocs command in `docs/readme.md`
7. Show a diff summary
**Last Updated:** YYYY-MM-DD
**Entry Points:** list of main files
## Quality
## Architecture
[ASCII diagram of component relationships]
## Key Modules
| Module | Purpose | Exports | Dependencies |
|--------|---------|---------|--------------|
| ... | ... | ... | ... |
## Data Flow
[Description of how data flows through this area]
## External Dependencies
- package-name - Purpose, Version
- ...
## Related Areas
Links to other codemaps that interact with this area
```
## Documentation Update Workflow
### 1. Extract Documentation from Code
```
- Read JSDoc/TSDoc comments
- Extract README sections from package.json
- Parse environment variables from .env.example
- Collect API endpoint definitions
```
### 2. Update Documentation Files
```
Files to update:
- README.md - Project overview, setup instructions
- docs/GUIDES/*.md - Feature guides, tutorials
- package.json - Descriptions, scripts docs
- API documentation - Endpoint specs
```
### 3. Documentation Validation
```
- Verify all mentioned files exist
- Check all links work
- Ensure examples are runnable
- Validate code snippets compile
```
## Example Project-Specific Codemaps
### API Codemap (docs/CODEMAPS/api.md)
```markdown
# API Architecture
**Last Updated:** YYYY-MM-DD
**Framework:** NestJS
**Entry Point:** src/main.ts
## Structure
src/
├── main.ts
├── app.module.ts
├── common/
├── config/
└── modules/
├── auth/
└── users/
## Data Flow
Client → Controller → Service → Drizzle Repository → PostgreSQL
## External Dependencies
- NestJS - Framework
- Drizzle ORM - Database
- PostgreSQL - Data store
- Redis - Cache / queues
```
```
### Backend Codemap (docs/CODEMAPS/backend.md)
```markdown
# Backend Architecture
**Last Updated:** YYYY-MM-DD
**Runtime:** NestJS
**Entry Point:** src/main.ts
## API Routes
| Route | Method | Purpose |
|-------|--------|---------|
| /users | GET | List users |
| /users | POST | Create user |
| /auth/login | POST | Authenticate |
## Data Flow
Controller → Service → Drizzle Repository → PostgreSQL
## External Services
- PostgreSQL via Drizzle
- Redis - cache / queues
```
### Integrations Codemap (docs/CODEMAPS/integrations.md)
```markdown
# External Integrations
**Last Updated:** YYYY-MM-DD
## Authentication (JWT)
- NestJS guards and strategies
- Session / token management
## Database (PostgreSQL + Drizzle)
- Schema in drizzle/schema.ts
- Versioned SQL migrations
- Least-privilege DB role
## Search (Redis + OpenAI)
- Vector embeddings (text-embedding-ada-002)
- Semantic search (KNN)
- Fallback to substring search
## Blockchain (Solana)
- Wallet integration
- Transaction handling
- Meteora CP-AMM SDK
```
## README Update Template
When updating README.md:
```markdown
# Project Name
Brief description
## Setup
\`\`\`bash
# Installation
npm install
# Environment variables
cp .env.example .env.local
# Fill in: OPENAI_API_KEY, REDIS_URL, etc.
# Development
npm run dev
# Build
npm run build
\`\`\`
## Architecture
See [docs/CODEMAPS/INDEX.md](docs/CODEMAPS/INDEX.md) for detailed architecture.
### Key Directories
- `src/modules` - NestJS feature modules
- `src/common` - Filters, guards, pipes, interceptors
- `drizzle/` - Schema and migrations
- `test/` - E2E tests
- `src/lib` - Utility libraries and clients
## Features
- [Feature 1] - Description
- [Feature 2] - Description
## Documentation
- [Setup Guide](docs/GUIDES/setup.md)
- [API Reference](docs/GUIDES/api.md)
- [Architecture](docs/CODEMAPS/INDEX.md)
## Contributing
See [CONTRIBUTING.md](CONTRIBUTING.md)
```
## Scripts to Power Documentation
### scripts/codemaps/generate.ts
```typescript
/**
* Generate codemaps from repository structure
* Usage: tsx scripts/codemaps/generate.ts
*/
import { Project } from 'ts-morph'
import * as fs from 'fs'
import * as path from 'path'
async function generateCodemaps() {
const project = new Project({
tsConfigFilePath: 'tsconfig.json',
})
// 1. Discover all source files
const sourceFiles = project.getSourceFiles('src/**/*.{ts,tsx}')
// 2. Build import/export graph
const graph = buildDependencyGraph(sourceFiles)
// 3. Detect entrypoints (pages, API routes)
const entrypoints = findEntrypoints(sourceFiles)
// 4. Generate codemaps
await generateFrontendMap(graph, entrypoints)
await generateBackendMap(graph, entrypoints)
await generateIntegrationsMap(graph)
// 5. Generate index
await generateIndex()
}
function buildDependencyGraph(files: SourceFile[]) {
// Map imports/exports between files
// Return graph structure
}
function findEntrypoints(files: SourceFile[]) {
// Identify pages, API routes, entry files
// Return list of entrypoints
}
```
### scripts/docs/update.ts
```typescript
/**
* Update documentation from code
* Usage: tsx scripts/docs/update.ts
*/
import * as fs from 'fs'
import { execSync } from 'child_process'
async function updateDocs() {
// 1. Read codemaps
const codemaps = readCodemaps()
// 2. Extract JSDoc/TSDoc
const apiDocs = extractJSDoc('src/**/*.ts')
// 3. Update README.md
await updateReadme(codemaps, apiDocs)
// 4. Update guides
await updateGuides(codemaps)
// 5. Generate API reference
await generateAPIReference(apiDocs)
}
function extractJSDoc(pattern: string) {
// Use jsdoc-to-markdown or similar
// Extract documentation from source
}
```
## Pull Request Template
When opening PR with documentation updates:
```markdown
## Docs: Update Codemaps and Documentation
### Summary
Regenerated codemaps and updated documentation to reflect current codebase state.
### Changes
- Updated docs/CODEMAPS/* from current code structure
- Refreshed README.md with latest setup instructions
- Updated docs/GUIDES/* with current API endpoints
- Added X new modules to codemaps
- Removed Y obsolete documentation sections
### Generated Files
- docs/CODEMAPS/INDEX.md
- docs/CODEMAPS/frontend.md
- docs/CODEMAPS/backend.md
- docs/CODEMAPS/integrations.md
### Verification
- [x] All links in docs work
- [x] Code examples are current
- [x] Architecture diagrams match reality
- [x] No obsolete references
### Impact
🟢 LOW - Documentation only, no code changes
See docs/CODEMAPS/INDEX.md for complete architecture overview.
```
## Maintenance Schedule
**Weekly:**
- Check for new files in src/ not in codemaps
- Verify README.md instructions work
- Update package.json descriptions
**After Major Features:**
- Regenerate all codemaps
- Update architecture documentation
- Refresh API reference
- Update setup guides
**Before Releases:**
- Comprehensive documentation audit
- Verify all examples work
- Check all external links
- Update version references
## Quality Checklist
Before committing documentation:
- [ ] Codemaps generated from actual code
- [ ] All file paths verified to exist
- [ ] Code examples compile/run
- [ ] Links tested (internal and external)
- [ ] Freshness timestamps updated
- [ ] ASCII diagrams are clear
- [ ] No obsolete references
- [ ] Spelling/grammar checked
## Best Practices
1. **Single Source of Truth** - Generate from code, don't manually write
2. **Freshness Timestamps** - Always include last updated date
3. **Token Efficiency** - Keep codemaps under 500 lines each
4. **Clear Structure** - Use consistent markdown formatting
5. **Actionable** - Include setup commands that actually work
6. **Linked** - Cross-reference related documentation
7. **Examples** - Show real working code snippets
8. **Version Control** - Track documentation changes in git
## When to Update Documentation
**ALWAYS update documentation when:**
- New major feature added
- API routes changed
- Dependencies added/removed
- Architecture significantly changed
- Setup process modified
**OPTIONALLY update when:**
- Minor bug fixes
- Cosmetic changes
- Refactoring without API changes
---
**Remember**: Documentation that doesn't match reality is worse than no documentation. Always generate from source of truth (the actual code).
- Every path mentioned must exist in the relevant repo
- Commands must match each repo's package manager scripts
- No user how-tos (menus, click-paths) in MkDocs — send those to `trackgo-fe/apps/docs-dev`
- No emoji; PlantUML for architecture and sequence diagrams
- Do not invent features or endpoints
+24
View File
@@ -0,0 +1,24 @@
---
description: TrackGo docs split — technical MkDocs here, user VitePress in trackgo-fe/apps/docs-dev
alwaysApply: true
---
# Documentation Split
TrackGo has two documentation kinds. Sibling repos: `trackgo-be`, `trackgo-fe`, `trackgo_mobile`.
| Kind | Audience | Location | Tooling |
| --- | --- | --- | --- |
| Technical | Developers / engineers | `docs/` + `mkdocs.yml` (this repo) | MkDocs / Backstage TechDocs |
| User | Customers / operators | `trackgo-fe/apps/docs-dev/` | VitePress |
## Rules
- Technical: stack, architecture, how to run, how modules interact (backend, web, landing, mobile).
- User: features and usage (web + mobile only). Never put user how-tos in MkDocs.
- Never put tech stack / package architecture / Electron IPC in docs-dev (user docs only going forward).
- No emoji in any documentation.
- Diagrams and sequence diagrams: PlantUML fenced blocks (` ```plantuml `), not Mermaid.
- Source of truth is the codebase. If a brief disagrees with code, follow the code or label **Coming soon**. Do not invent features.
Authoring details: `.cursor/rules/technical-docs.mdc` (when editing `docs/` or `mkdocs.yml`).
+1 -1
View File
@@ -36,7 +36,7 @@ interface PaginationMeta {
- Mark every list endpoint with `@Pagination()`
- Return `{ data, total }` from the handler — **never** build `meta` in the service or controller
- Query: `page`/`limit` or `offset`/`limit` (defaults `page=1`, `limit=10`; max limit `200`)
- Query: `page`/`limit` or `offset`/`limit` (defaults `page=1`, `limit=10`; max limit `200`) plus `orderBy`/`orderType` (`ASC` | `DESC`, default `ASC`)
- Use `@RawResponse()` for file downloads / health probes that must skip wrapping
- Non-list handlers (detail, create, update, delete, status, import) pass through **unwrapped**
+3 -3
View File
@@ -11,7 +11,7 @@ alwaysApply: false
Every **non-public** controller handler on a primary (CRUD) resource MUST use:
```typescript
@RequirePrivilege('MODULE.RESOURCE', 'view' | 'create' | 'update' | 'delete' | 'import')
@RequirePrivilege('GROUP.PARENT.MODULE' | ['ADMIN.SALES.ACTIVITIES.PLAN', 'MOBILE.SALES.PLAN'], 'view' | 'create' | 'update' | 'delete' | 'import')
```
Map HTTP verbs to actions:
@@ -24,13 +24,13 @@ Map HTTP verbs to actions:
| `DELETE /:id`, bulk-delete | `delete` |
| `POST /import` | `import` |
Key codes use dotted uppercase module levels (`PRIVILEGES`, `SALES.INVOICE`). New modules add a `privilege_keys` seed row via migration — do not invent a parallel permission helper.
Key codes use 3- or 4-part dotted uppercase hierarchy: `Group.Parent.Module` or `Group.Parent.Module.Submodule` (e.g. `ADMIN.SALES.ACTIVITIES.INVOICE`, `MOBILE.SALES.PLAN`). Pass a string or string array to `@RequirePrivilege`; arrays use OR semantics. New modules add `privilege_keys` rows via migration — do not invent a parallel permission helper.
Seed an Administrator privilege only via SQL/ops after the first user exists (`created_by` requires a user). Documented bootstrap: insert privilege + details, then `UPDATE users SET privilege_id = …`. Do not auto-grant on register.
## Guard behavior
`PrivilegesGuard` (global) allows when there is no metadata. When metadata is present, `users.is_superadmin === true` skips the matrix check. Otherwise the user’s assigned privilege must be **status `active`** and the matrix cell must be `value === true`, or the request is `403 Forbidden`. Missing privilege / draft / archived / missing cell / `false` → deny.
`PrivilegesGuard` (global) allows when there is no metadata. When metadata is present, `users.is_superadmin === true` skips the matrix check. Otherwise the user’s assigned privilege must be **status `active`** and at least one matrix cell in the required key list must be `value === true`, or the request is `403 Forbidden`. Missing privilege / draft / archived / missing cell / `false` → deny.
Do not set `is_superadmin` via register/login. Default is `false`; promote via SQL/ops (`UPDATE users SET is_superadmin = true`). The flag is loaded from the database on each JWT validation (not from JWT claims).
+2 -1
View File
@@ -35,9 +35,10 @@ Register static write paths (`import`, `bulk-delete`, `bulk-status`) **before**
List requirements:
- Query filters for the resource’s own attributes **plus** `search` (case-insensitive match on the module’s searchable text columns; AND with other filters)
- Shared pagination query (`page`/`limit` or `offset`/`limit`) via `PaginationQueryDto`
- Shared pagination query (`page`/`limit` or `offset`/`limit`) plus `orderBy`/`orderType` via `PaginationQueryDto`
- Handler **must** use `@Pagination()` and return `{ data, total }` — never build `meta` here (see `.cursor/rules/pagination-response.mdc`)
- Service `visibleFields` whitelist: default **all non-secret** attributes; modules may narrow. Project in the **service**, not the controller
- FK relations in list/detail (and write responses that reuse the mapper) MUST be nested objects via `pickRelation` — see `.cursor/rules/relation-response.mdc`
- List query must be extendable (e.g. `extendListQuery(qb, filters)` on the repository/service) so joins/extra predicates can be added without forking list
## Write controller
+33
View File
@@ -0,0 +1,33 @@
---
description: List/detail (and write responses that reuse the mapper) embed FK relations as objects via pickRelation
globs: "src/modules/**/*.ts,src/common/http/response/**/*.ts"
alwaysApply: false
---
# Relation Response Objects
List, detail, and write handlers that reuse the same mapper MUST embed foreign keys as nested objects, not bare ids.
## Field lists
- Default catalog fields: `DEFAULT_RELATION_FIELDS` (`id`, `code`, `name`) from `src/common/http/response/`
- Override per entity with a module/local constant (users: `USER_RELATION_FIELDS` = `id`, `username`)
- Use `pickRelation(source, fields)` only — do not hand-roll partial copies
## Mapping
- Request DTOs still accept `*Id` (`divisionId`); the response key is the relation name (`division`, not `divisionId`)
- Null FK → `null` (not omitted)
- Never expose secrets (`passwordHash`, tokens) in relation objects
- Load relations in the repository (joins or batch-load); map with `pickRelation` in the service
```typescript
// BAD
return { divisionId: branch.divisionId, createdBy: branch.createdBy }
// GOOD
return {
division: pickRelation(branch.division, DEFAULT_RELATION_FIELDS),
createdBy: pickRelation(branch.createdByUser, USER_RELATION_FIELDS),
}
```
+79
View File
@@ -0,0 +1,79 @@
---
description: How to write TrackGo technical docs in MkDocs (docs/ + mkdocs.yml)
globs: docs/**/*.md,mkdocs.yml
alwaysApply: false
---
# Technical Documentation (MkDocs)
Audience: developers and engineers. Location: `docs/` + `mkdocs.yml`.
## Scope
Document tech stack, architecture, how to run, and how modules interact for:
| Area | Sources |
| --- | --- |
| Backend | this repo — `src/app.module.ts`, `src/modules/` |
| Frontend web | sibling `trackgo-fe/apps/web` |
| Frontend landing | sibling `trackgo-fe/apps/landing` (public SPA, not the ERP) |
| Mobile | sibling `trackgo_mobile` |
Do **not** write customer how-tos here — those belong in `trackgo-fe/apps/docs-dev`.
## Layout
Prefer these folders under `docs/`:
```text
docs/
backend/
frontend-web/
frontend-landing/
mobile/
api.md # keep existing API reference
report-*.md # keep existing report docs
```
When adding pages:
1. Create the markdown under the matching folder.
2. Register it in `mkdocs.yml` `nav`.
3. Set `site_name` to TrackGo (it may still say `Concourse CI` — rename when you touch nav).
Preview: Docker TechDocs command in `docs/readme.md`.
## Ground in code
- Backend modules from `src/app.module.ts`: Auth, Users, Privileges, Configuration, Sales, Field, Reports.
- Web menus / RBAC keys: `trackgo-fe/apps/web/src/apps/main/layouts/data/menu.data.ts`.
- Mobile routes: `trackgo_mobile/lib/config/router.dart` (login, home, plan, customers, check-in, payment).
- Cross-read sibling repos; do not invent features. Brief vs code → follow code or **Coming soon**.
## Style
- No emoji.
- Diagrams / sequences: PlantUML only.
````markdown
```plantuml
@startuml
Alice -> Bob: request
Bob --> Alice: response
@enduml
```
````
```text
# BAD — user how-to in MkDocs
docs/how-to-create-invoice.md
# BAD — Mermaid or emoji
```mermaid
```
"🚀 Getting Started"
# GOOD
docs/backend/architecture.md
docs/frontend-web/modules.md
```
+4
View File
@@ -23,3 +23,7 @@ BCRYPT_SALT_ROUNDS=10
# OpenAPI UI at /docs (default: on unless NODE_ENV=production)
# SWAGGER_ENABLED=true
# SWAGGER_ENABLED=false
# Debug only. Skip GPS radius/location checks on check-in and check-out.
# Rejected when NODE_ENV=production.
# SKIP_GPS_VALIDATION=true
+115
View File
@@ -0,0 +1,115 @@
# TrackGo HTTP API
JSON keys are camelCase. IDs are UUID v4. List endpoints return `{ data, meta }`.
List query includes shared pagination (`page`/`limit` or `offset`/`limit`) plus **`orderBy`** (resource field name) and **`orderType`** (`ASC` or `DESC`, default `ASC`). Unknown `orderBy` values are rejected. Defaults when omitted: users `username`; cycles `cycleNumber`; plans `date`; privilege-keys `sortOrder` then `code`; other lists `code`. Foreign keys on list/detail responses are nested objects (`{ id, code, name }` or `{ id, username }` / `{ id, code }`), not bare UUIDs.
List filters: `username`, `privilegeId`, `status`, `search` (username), `orderBy`, `orderType`.
## Auth
### `POST /auth/register` — public — `201`
Creates a **draft** user. Does **not** issue tokens.
```json
{ "username": "alice", "password": "password123" }
```
Response: `{ "id": "uuid", "username": "alice", "status": "draft" }`.
Activate with `PATCH /users/:id/status` `{ "status": "active" }` (or SQL bootstrap) then `POST /auth/login`.
### `POST /auth/login` — public — `200`
Same body as register. Returns `{ accessToken, refreshToken }`.
Login, refresh, and JWT validation require `user.status === "active"`. If the user is assigned to an employee, that employee must also be `active`. Failures use `401` with a generic credentials message.
### `POST /auth/refresh` — public — `200`
### `POST /auth/revoke` — public — `204`
### `GET /auth/me` — bearer — `200`
## Users
Key: `USERS`. **Standard CRUD + import** (list, detail, create, update, status, delete, bulk-delete, bulk-status, import). Extra: `PATCH /users/:id/privilege`.
| Method | Path | Action | Status |
| --- | --- | --- | --- |
| `GET` | `/users` | view | 200 |
| `GET` | `/users/:id` | view | 200 |
| `POST` | `/users` | create | 201 |
| `PATCH` | `/users/:id` | update | 200 |
| `PATCH` | `/users/:id/status` | update | 200 |
| `PATCH` | `/users/:id/privilege` | update | 200 |
| `DELETE` | `/users/:id` | delete | 204 |
| `POST` | `/users/bulk-delete` | delete | 200 |
| `POST` | `/users/bulk-status` | update | 200 |
| `POST` | `/users/import` | import | 200 |
**Create:** `{ username, password, privilegeId?, status?, employeeId? }`. Username 3–32, `^[a-zA-Z0-9_]+$`, stored lowercased. Password 8–72, write-only. Omit status → `draft`. Never send `isSuperadmin` / `passwordHash`.
Optional `employeeId` links an existing employee. Unique assigned user → `409`.
**Update** `PATCH /users/:id`: `username?`, `password?`, `privilegeId?` (`null` clears), `employeeId?` (`null` unlinks). No `status`. `employeeId` reassigns the linked employee.
**Privilege:** `{ privilegeId }` (`null` clears). Assigned privilege must be **active**. Response is the full `UserDto`.
List filters: `username`, `privilegeId`, `status`, `search` (username).
**DTO:**
```json
{
"id": "uuid",
"username": "alice",
"isSuperadmin": false,
"privilege": { "id": "uuid", "code": "ADMIN", "name": "Administrator" },
"employee": { "id": "uuid", "code": "EMP_01", "name": "Ada Lovelace" },
"status": "active",
"createdAt": 1710000000000,
"updatedAt": 1710000000000,
"createdBy": { "id": "uuid", "username": "admin" },
"updatedBy": { "id": "uuid", "username": "admin" }
}
```
`privilege` / `employee` may be `null`. CSV required: `username`, `password`. Optional: `privilegeId`, `status`. Delete of a user still referenced as `created_by` / `updated_by` → `409`.
Bootstrap: first user is draft until `UPDATE users SET status = 'active'`.
## Employees
Create/update optional `userId` (assign an existing login user) or nested `user` (`id?`, `username?`, `password?`). Nested `user` without `id` creates a login user (`username` + `password` required) or updates the currently linked username. `user.id` / `userId` links an existing user; `username` may be updated, but `password` is rejected (use `PATCH /users/:id`). Nested `user` cannot set `privilegeId`. `user: null` or `userId: null` unlinks. Users link the other way with `employeeId`. DTO nests `user: { id, username } | null`. List filter `userId`. List filter `position` as one or more of `sales` | `driver` | `crew` (`?position=sales&position=driver`). CSV optional `userId`. Unique assigned user → `409`.
## Reports
Privilege keys: `SALES.REPORT`, `LOGISTICS.REPORT` (seeded in migration `0013_reports`).
### `GET /reports/config` — bearer — `200`
Query: `groupNames` (e.g. `sales_report`). Returns report configs visible to the caller, each with optional `activeFilter` and `activeTableConfig` bookmarks.
### `POST /reports/data` — bearer — `200`
Body: `{ groupName, uniqueName, queryModel }`. Returns row array keyed by column id.
### `POST /reports/meta` — bearer — `200`
Same body as data. Returns `{ totalRow, limit, offset }`.
### Report bookmarks
| Method | Path | Notes |
| --- | --- | --- |
| `GET` | `/report-bookmarks` | List for current user (`@Pagination()`) |
| `GET` | `/report-bookmarks/label-history` | Distinct labels |
| `GET` | `/report-bookmarks/applied` | Query: `groupName`, `uniqueName`, `type` |
| `POST` | `/report-bookmarks` | Create (`201`) |
| `PUT` | `/report-bookmarks/applied/:id` | Apply (unapplies siblings) |
| `PUT` | `/report-bookmarks/unapplied/:id` | Clear applied |
| `DELETE` | `/report-bookmarks/:id` | `204` |
Bookmark `type`: `FILTER_TABLE` | `TABLE_CONFIG`. `configuration` is opaque JSON (filter form values or AG Grid column state).
+8
View File
@@ -0,0 +1,8 @@
## Docs
Using backstage with mkdocs.yml
Preview :
```
docker run --rm -w /content -v $(pwd):/content -p 8000:8000 -it harbor.eigen.co.id/eigen/techdocs:1.0.1 serve -a 0.0.0.0:8000
```
+52
View File
@@ -0,0 +1,52 @@
# TrackGo Report Engine
Config-driven reporting for `trackgo-be` (`src/modules/reports`) and `trackgo-fe` (`apps/web/src/core/report`).
## Architecture
1. **Report config** — TypeScript object per report (`shared/configs/`). Defines SQL `tableSchema`, columns, filters, and `privilegeKey`.
2. **Query builder** — `ReportQueryBuilder` compiles AG Grid `queryModel` + config into parameterized Drizzle SQL (`db.execute`).
3. **Generic UI** — `ReportProvider` loads configs for a `groupName` and renders one tab per report via `ReportTable` (AG Grid Server-Side Row Model).
Persisted engine data:
- `report_bookmarks` — saved filters (`FILTER_TABLE`) and table layouts (`TABLE_CONFIG`)
Report rows are **never** stored; they are queried live from business tables.
## Groups and privilege keys
| Group | `groupName` | Privilege key | Menu path |
| --- | --- | --- | --- |
| Sales reports | `sales_report` | `SALES.REPORT` | `/app/sales/reports/index` |
| Logistics reports | `logistics_report` | `LOGISTICS.REPORT` | `/app/logistics/reports/index` |
## HTTP APIs
| Method | Path | Body / query |
| --- | --- | --- |
| `GET` | `/reports/config` | `groupNames` (array or repeated) |
| `POST` | `/reports/data` | `{ groupName, uniqueName, queryModel }` |
| `POST` | `/reports/meta` | same as data → `{ totalRow, limit, offset }` |
| `GET` | `/report-bookmarks` | list filters (`groupName`, `uniqueName`, `type`, pagination) |
| `POST` | `/report-bookmarks` | create bookmark |
| `PUT` | `/report-bookmarks/applied/:id` | apply |
| `PUT` | `/report-bookmarks/unapplied/:id` | unapply |
| `DELETE` | `/report-bookmarks/:id` | delete |
All endpoints require JWT. Report data/config endpoints use `ReportPrivilegeGuard` (config `privilegeKey` + `view`). Bookmarks are scoped to `createdBy` (current user).
## Adding a report
1. Add a `ReportConfigEntity` file under `shared/configs/`.
2. Register it in `shared/configs/index.ts`.
3. No new controller or React page — the generic UI picks it up when `groupName` matches.
## TrackGo-specific notes
- JSON uses **camelCase** (`groupName`, `queryModel`, `columnConfigs`).
- SQL values are **bound parameters**; only config-authored fragments use `sql.raw()`.
- Cell formatting uses `DateTime`, `Status`, and `Decimal` value objects.
- Excel export is **not** implemented in this phase.
See [report-list.md](./report-list.md) for the seven shipped reports.
+104
View File
@@ -0,0 +1,104 @@
# TrackGo Reports
Reports implemented in the report engine. Columns reflect **available data** only — fields from the legacy PMPS UI without backing tables are omitted.
## Sales reports (`sales_report`)
### Report Sales Order
| Column | Source |
| --- | --- |
| Date | `sales_orders.date` |
| Branch | `branches.name` |
| Division | `divisions.name` |
| No. Sales Order | `sales_orders.code` |
| Customer | `customers.name` |
| Invoice Amount | `SUM(sales_order_products.quantity * price)` |
| Sales Rep. | `employees.name` |
| Last Status Order | `sales_orders.status` |
### Report Request Order
| Column | Source |
| --- | --- |
| Date | `sales_requests.date` |
| Branch | `branches.name` |
| Division | `divisions.name` |
| No. Request Order | `sales_requests.code` |
| Customer | `customers.name` |
| Sales Rep. | `employees.name` |
| Status | `sales_requests.status` |
### Report Invoice
| Column | Source |
| --- | --- |
| Date | `sales_invoices.date` |
| Branch | `branches.name` |
| Division | `divisions.name` |
| Customer | `customers.name` |
| Customer code | `customers.code` |
| Sales Order No. | `sales_invoices.sales_order_code` |
| Invoice No. | `sales_invoices.code` |
| Status | `sales_invoices.status` |
| Sales Rep. | `employees.name` |
| Balance | `sales_invoices.balance` |
### Report Payment
| Column | Source |
| --- | --- |
| Date | `sales_payments.date` |
| Payment No. | `sales_payments.code` |
| Customer code | via `sales_invoices` → `customers.code` |
| Branch | via invoice → `branches.name` |
| Division | via invoice → `divisions.name` |
| Sales Rep | via invoice → `employees.name` |
| Invoice ID | `sales_invoices.code` |
| Invoice Amount | `sales_invoices.balance` |
| Payment Amount | `sales_payment_invoices.amount` |
| Status | `sales_payments.status` |
### Report Visit Plan
| Column | Source |
| --- | --- |
| Date | `plans.date` (`purpose = sales`) |
| Sales Rep | `employees.name` |
| Branch | start branch name |
| Plan | count of `plan_destinations` |
| Invoice | count of `plan_invoices` |
| Status | `plans.status` |
## Logistics reports (`logistics_report`)
### Report Packing Slip
| Column | Source |
| --- | --- |
| Date | `packing_slips.date` |
| Sales Order No. | `packing_slips.sales_order_number` |
| Packing Slip No. | `packing_slips.code` |
| Customer | `customers.name` |
| Status | `packing_slips.status` |
### Report Delivery Plan
| Column | Source |
| --- | --- |
| Date | `plans.date` (`purpose = logistics`) |
| Sales Rep | `employees.name` (driver) |
| Branch | start branch name |
| Plan | count of `plan_destinations` |
| Packing Slip | count of `plan_packing_slips` |
| Status | `plans.status` |
## Not built (no backing data)
These reports from the legacy PMPS list require visit tracking, permissions, or alerts tables that do not exist in TrackGo:
- Report Performance (sales and logistic)
- Report Sales Permission / Report Logistic Permission
- Report Alert (sales and logistic)
Also omitted as columns everywhere: Visited, Break Time, Driving, Stop Time, Cancel, Alert counts, and live visit actuals.
+35
View File
@@ -0,0 +1,35 @@
CREATE TABLE "customers" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"code" varchar(16) NOT NULL,
"name" varchar(64) NOT NULL,
"phone" text NOT NULL,
"address" text NOT NULL,
"latitude" double precision,
"longitude" double precision,
"nfc_id" text,
"status" text DEFAULT 'draft' NOT NULL,
"created_at" bigint NOT NULL,
"updated_at" bigint NOT NULL,
"created_by" uuid NOT NULL,
"updated_by" uuid NOT NULL
);
--> statement-breakpoint
CREATE TABLE "customer_contacts" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"customer_id" uuid NOT NULL,
"name" varchar(64) NOT NULL,
"job_title" varchar(64),
"phone" text,
"mobile_phone" text,
"notes" text
);
--> statement-breakpoint
ALTER TABLE "customers" ADD CONSTRAINT "customers_created_by_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "customers" ADD CONSTRAINT "customers_updated_by_users_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "customer_contacts" ADD CONSTRAINT "customer_contacts_customer_id_customers_id_fk" FOREIGN KEY ("customer_id") REFERENCES "public"."customers"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "customers_code_unique" ON "customers" USING btree ("code");--> statement-breakpoint
CREATE UNIQUE INDEX "customers_nfc_id_unique" ON "customers" USING btree ("nfc_id");--> statement-breakpoint
CREATE INDEX "customer_contacts_customer_id_idx" ON "customer_contacts" USING btree ("customer_id");
--> statement-breakpoint
INSERT INTO "privilege_keys" ("code", "label", "sort_order") VALUES
('CONFIGURATION.CUSTOMER', 'Customers', 5);
+19
View File
@@ -0,0 +1,19 @@
CREATE TABLE "employees" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"code" varchar(16) NOT NULL,
"name" varchar(64) NOT NULL,
"phone" text NOT NULL,
"position" text NOT NULL,
"status" text DEFAULT 'draft' NOT NULL,
"created_at" bigint NOT NULL,
"updated_at" bigint NOT NULL,
"created_by" uuid NOT NULL,
"updated_by" uuid NOT NULL
);
--> statement-breakpoint
ALTER TABLE "employees" ADD CONSTRAINT "employees_created_by_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "employees" ADD CONSTRAINT "employees_updated_by_users_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "employees_code_unique" ON "employees" USING btree ("code");
--> statement-breakpoint
INSERT INTO "privilege_keys" ("code", "label", "sort_order") VALUES
('CONFIGURATION.EMPLOYEE', 'Employees', 6);
+20
View File
@@ -0,0 +1,20 @@
CREATE TABLE "products" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"code" varchar(32) NOT NULL,
"name" varchar(128) NOT NULL,
"unit" varchar(16),
"price" numeric(18, 4),
"brand" varchar(64),
"status" text DEFAULT 'draft' NOT NULL,
"created_at" bigint NOT NULL,
"updated_at" bigint NOT NULL,
"created_by" uuid NOT NULL,
"updated_by" uuid NOT NULL
);
--> statement-breakpoint
ALTER TABLE "products" ADD CONSTRAINT "products_created_by_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "products" ADD CONSTRAINT "products_updated_by_users_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "products_code_unique" ON "products" USING btree ("code");
--> statement-breakpoint
INSERT INTO "privilege_keys" ("code", "label", "sort_order") VALUES
('CONFIGURATION.PRODUCT', 'Products', 7);
+252
View File
@@ -0,0 +1,252 @@
CREATE TABLE "document_sequences" (
"prefix" varchar(8) NOT NULL,
"period" varchar(8) NOT NULL,
"last_value" integer NOT NULL,
CONSTRAINT "document_sequences_prefix_period_pk" PRIMARY KEY("prefix","period")
);
--> statement-breakpoint
CREATE TABLE "sales_requests" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"code" varchar(32) NOT NULL,
"date" bigint NOT NULL,
"sales_person_id" uuid NOT NULL,
"branch_id" uuid NOT NULL,
"division_id" uuid NOT NULL,
"customer_id" uuid NOT NULL,
"address" text NOT NULL,
"latitude" double precision,
"longitude" double precision,
"notes" text,
"status" text DEFAULT 'draft' NOT NULL,
"created_at" bigint NOT NULL,
"updated_at" bigint NOT NULL,
"created_by" uuid NOT NULL,
"updated_by" uuid NOT NULL
);
--> statement-breakpoint
CREATE TABLE "sales_request_products" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"sales_request_id" uuid NOT NULL,
"product_id" uuid NOT NULL,
"quantity" numeric(18, 4) NOT NULL,
"price" numeric(18, 4) NOT NULL
);
--> statement-breakpoint
CREATE TABLE "sales_request_images" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"sales_request_id" uuid NOT NULL,
"url" varchar(2048) NOT NULL,
"description" varchar(255)
);
--> statement-breakpoint
CREATE TABLE "sales_orders" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"code" varchar(32) NOT NULL,
"sales_request_id" uuid,
"date" bigint NOT NULL,
"sales_person_id" uuid NOT NULL,
"branch_id" uuid NOT NULL,
"division_id" uuid NOT NULL,
"customer_id" uuid NOT NULL,
"address" text NOT NULL,
"latitude" double precision,
"longitude" double precision,
"notes" text,
"status" text DEFAULT 'draft' NOT NULL,
"created_at" bigint NOT NULL,
"updated_at" bigint NOT NULL,
"created_by" uuid NOT NULL,
"updated_by" uuid NOT NULL
);
--> statement-breakpoint
CREATE TABLE "sales_order_products" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"sales_order_id" uuid NOT NULL,
"product_id" uuid NOT NULL,
"quantity" numeric(18, 4) NOT NULL,
"price" numeric(18, 4) NOT NULL
);
--> statement-breakpoint
CREATE TABLE "sales_order_images" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"sales_order_id" uuid NOT NULL,
"url" varchar(2048) NOT NULL,
"description" varchar(255)
);
--> statement-breakpoint
CREATE TABLE "packing_slips" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"code" varchar(32) NOT NULL,
"sales_order_id" uuid,
"sales_order_number" varchar(32),
"date" bigint NOT NULL,
"customer_id" uuid NOT NULL,
"address" text NOT NULL,
"latitude" double precision,
"longitude" double precision,
"notes" text,
"status" text DEFAULT 'draft' NOT NULL,
"created_at" bigint NOT NULL,
"updated_at" bigint NOT NULL,
"created_by" uuid NOT NULL,
"updated_by" uuid NOT NULL
);
--> statement-breakpoint
CREATE TABLE "packing_slip_products" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"packing_slip_id" uuid NOT NULL,
"product_id" uuid NOT NULL,
"quantity" numeric(18, 4) NOT NULL,
"price" numeric(18, 4) NOT NULL
);
--> statement-breakpoint
CREATE TABLE "sales_invoices" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"code" varchar(32) NOT NULL,
"date" bigint NOT NULL,
"sales_person_id" uuid NOT NULL,
"branch_id" uuid NOT NULL,
"division_id" uuid NOT NULL,
"customer_id" uuid NOT NULL,
"sales_order_id" uuid,
"sales_order_code" varchar(32),
"packing_slip_id" uuid,
"packing_slip_code" varchar(32),
"balance" numeric(18, 4) NOT NULL,
"notes" text,
"status" text DEFAULT 'draft' NOT NULL,
"created_at" bigint NOT NULL,
"updated_at" bigint NOT NULL,
"created_by" uuid NOT NULL,
"updated_by" uuid NOT NULL
);
--> statement-breakpoint
CREATE TABLE "sales_invoice_products" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"sales_invoice_id" uuid NOT NULL,
"product_id" uuid NOT NULL,
"quantity" numeric(18, 4) NOT NULL,
"price" numeric(18, 4) NOT NULL
);
--> statement-breakpoint
CREATE TABLE "sales_payments" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"code" varchar(32) NOT NULL,
"date" bigint NOT NULL,
"notes" text,
"status" text DEFAULT 'draft' NOT NULL,
"created_at" bigint NOT NULL,
"updated_at" bigint NOT NULL,
"created_by" uuid NOT NULL,
"updated_by" uuid NOT NULL
);
--> statement-breakpoint
CREATE TABLE "sales_payment_images" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"sales_payment_id" uuid NOT NULL,
"url" varchar(2048) NOT NULL,
"description" varchar(255)
);
--> statement-breakpoint
CREATE TABLE "sales_payment_invoices" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"sales_payment_id" uuid NOT NULL,
"sales_invoice_id" uuid NOT NULL,
"amount" numeric(18, 4) NOT NULL
);
--> statement-breakpoint
ALTER TABLE "sales_requests" ADD CONSTRAINT "sales_requests_sales_person_id_employees_id_fk" FOREIGN KEY ("sales_person_id") REFERENCES "public"."employees"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sales_requests" ADD CONSTRAINT "sales_requests_branch_id_branches_id_fk" FOREIGN KEY ("branch_id") REFERENCES "public"."branches"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sales_requests" ADD CONSTRAINT "sales_requests_division_id_divisions_id_fk" FOREIGN KEY ("division_id") REFERENCES "public"."divisions"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sales_requests" ADD CONSTRAINT "sales_requests_customer_id_customers_id_fk" FOREIGN KEY ("customer_id") REFERENCES "public"."customers"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sales_requests" ADD CONSTRAINT "sales_requests_created_by_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sales_requests" ADD CONSTRAINT "sales_requests_updated_by_users_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sales_request_products" ADD CONSTRAINT "sales_request_products_sales_request_id_sales_requests_id_fk" FOREIGN KEY ("sales_request_id") REFERENCES "public"."sales_requests"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sales_request_products" ADD CONSTRAINT "sales_request_products_product_id_products_id_fk" FOREIGN KEY ("product_id") REFERENCES "public"."products"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sales_request_images" ADD CONSTRAINT "sales_request_images_sales_request_id_sales_requests_id_fk" FOREIGN KEY ("sales_request_id") REFERENCES "public"."sales_requests"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sales_orders" ADD CONSTRAINT "sales_orders_sales_request_id_sales_requests_id_fk" FOREIGN KEY ("sales_request_id") REFERENCES "public"."sales_requests"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sales_orders" ADD CONSTRAINT "sales_orders_sales_person_id_employees_id_fk" FOREIGN KEY ("sales_person_id") REFERENCES "public"."employees"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sales_orders" ADD CONSTRAINT "sales_orders_branch_id_branches_id_fk" FOREIGN KEY ("branch_id") REFERENCES "public"."branches"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sales_orders" ADD CONSTRAINT "sales_orders_division_id_divisions_id_fk" FOREIGN KEY ("division_id") REFERENCES "public"."divisions"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sales_orders" ADD CONSTRAINT "sales_orders_customer_id_customers_id_fk" FOREIGN KEY ("customer_id") REFERENCES "public"."customers"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sales_orders" ADD CONSTRAINT "sales_orders_created_by_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sales_orders" ADD CONSTRAINT "sales_orders_updated_by_users_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sales_order_products" ADD CONSTRAINT "sales_order_products_sales_order_id_sales_orders_id_fk" FOREIGN KEY ("sales_order_id") REFERENCES "public"."sales_orders"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sales_order_products" ADD CONSTRAINT "sales_order_products_product_id_products_id_fk" FOREIGN KEY ("product_id") REFERENCES "public"."products"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sales_order_images" ADD CONSTRAINT "sales_order_images_sales_order_id_sales_orders_id_fk" FOREIGN KEY ("sales_order_id") REFERENCES "public"."sales_orders"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "packing_slips" ADD CONSTRAINT "packing_slips_sales_order_id_sales_orders_id_fk" FOREIGN KEY ("sales_order_id") REFERENCES "public"."sales_orders"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "packing_slips" ADD CONSTRAINT "packing_slips_customer_id_customers_id_fk" FOREIGN KEY ("customer_id") REFERENCES "public"."customers"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "packing_slips" ADD CONSTRAINT "packing_slips_created_by_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "packing_slips" ADD CONSTRAINT "packing_slips_updated_by_users_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "packing_slip_products" ADD CONSTRAINT "packing_slip_products_packing_slip_id_packing_slips_id_fk" FOREIGN KEY ("packing_slip_id") REFERENCES "public"."packing_slips"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "packing_slip_products" ADD CONSTRAINT "packing_slip_products_product_id_products_id_fk" FOREIGN KEY ("product_id") REFERENCES "public"."products"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sales_invoices" ADD CONSTRAINT "sales_invoices_sales_person_id_employees_id_fk" FOREIGN KEY ("sales_person_id") REFERENCES "public"."employees"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sales_invoices" ADD CONSTRAINT "sales_invoices_branch_id_branches_id_fk" FOREIGN KEY ("branch_id") REFERENCES "public"."branches"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sales_invoices" ADD CONSTRAINT "sales_invoices_division_id_divisions_id_fk" FOREIGN KEY ("division_id") REFERENCES "public"."divisions"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sales_invoices" ADD CONSTRAINT "sales_invoices_customer_id_customers_id_fk" FOREIGN KEY ("customer_id") REFERENCES "public"."customers"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sales_invoices" ADD CONSTRAINT "sales_invoices_sales_order_id_sales_orders_id_fk" FOREIGN KEY ("sales_order_id") REFERENCES "public"."sales_orders"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sales_invoices" ADD CONSTRAINT "sales_invoices_packing_slip_id_packing_slips_id_fk" FOREIGN KEY ("packing_slip_id") REFERENCES "public"."packing_slips"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sales_invoices" ADD CONSTRAINT "sales_invoices_created_by_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sales_invoices" ADD CONSTRAINT "sales_invoices_updated_by_users_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sales_invoice_products" ADD CONSTRAINT "sales_invoice_products_sales_invoice_id_sales_invoices_id_fk" FOREIGN KEY ("sales_invoice_id") REFERENCES "public"."sales_invoices"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sales_invoice_products" ADD CONSTRAINT "sales_invoice_products_product_id_products_id_fk" FOREIGN KEY ("product_id") REFERENCES "public"."products"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sales_payments" ADD CONSTRAINT "sales_payments_created_by_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sales_payments" ADD CONSTRAINT "sales_payments_updated_by_users_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sales_payment_images" ADD CONSTRAINT "sales_payment_images_sales_payment_id_sales_payments_id_fk" FOREIGN KEY ("sales_payment_id") REFERENCES "public"."sales_payments"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sales_payment_invoices" ADD CONSTRAINT "sales_payment_invoices_sales_payment_id_sales_payments_id_fk" FOREIGN KEY ("sales_payment_id") REFERENCES "public"."sales_payments"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sales_payment_invoices" ADD CONSTRAINT "sales_payment_invoices_sales_invoice_id_sales_invoices_id_fk" FOREIGN KEY ("sales_invoice_id") REFERENCES "public"."sales_invoices"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "sales_requests_code_unique" ON "sales_requests" USING btree ("code");
--> statement-breakpoint
CREATE UNIQUE INDEX "sales_orders_code_unique" ON "sales_orders" USING btree ("code");
--> statement-breakpoint
CREATE UNIQUE INDEX "packing_slips_code_unique" ON "packing_slips" USING btree ("code");
--> statement-breakpoint
CREATE UNIQUE INDEX "sales_invoices_code_unique" ON "sales_invoices" USING btree ("code");
--> statement-breakpoint
CREATE UNIQUE INDEX "sales_payments_code_unique" ON "sales_payments" USING btree ("code");
--> statement-breakpoint
INSERT INTO "privilege_keys" ("code", "label", "sort_order") VALUES
('SALES.REQUEST', 'Sales requests', 8),
('SALES.ORDER', 'Sales orders', 9),
('SALES.PACKING_SLIP', 'Packing slips', 10),
('SALES.INVOICE', 'Sales invoices', 11),
('SALES.PAYMENT', 'Sales payments', 12);
--> statement-breakpoint
ALTER TABLE "packing_slips" ADD COLUMN "sales_person_id" uuid;--> statement-breakpoint
ALTER TABLE "packing_slips" ADD COLUMN "branch_id" uuid;--> statement-breakpoint
ALTER TABLE "packing_slips" ADD COLUMN "division_id" uuid;--> statement-breakpoint
ALTER TABLE "sales_invoices" ADD COLUMN "address" text;--> statement-breakpoint
ALTER TABLE "sales_invoices" ADD COLUMN "latitude" double precision;--> statement-breakpoint
ALTER TABLE "sales_invoices" ADD COLUMN "longitude" double precision;--> statement-breakpoint
ALTER TABLE "sales_payments" ADD COLUMN "sales_person_id" uuid;--> statement-breakpoint
ALTER TABLE "sales_payments" ADD COLUMN "branch_id" uuid;--> statement-breakpoint
ALTER TABLE "sales_payments" ADD COLUMN "division_id" uuid;--> statement-breakpoint
ALTER TABLE "sales_payments" ADD COLUMN "customer_id" uuid;--> statement-breakpoint
ALTER TABLE "sales_payments" ADD COLUMN "address" text;--> statement-breakpoint
ALTER TABLE "sales_payments" ADD COLUMN "latitude" double precision;--> statement-breakpoint
ALTER TABLE "sales_payments" ADD COLUMN "longitude" double precision;--> statement-breakpoint
CREATE TABLE "packing_slip_images" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"packing_slip_id" uuid NOT NULL,
"url" varchar(2048) NOT NULL,
"description" varchar(255)
);
--> statement-breakpoint
CREATE TABLE "sales_invoice_images" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"sales_invoice_id" uuid NOT NULL,
"url" varchar(2048) NOT NULL,
"description" varchar(255)
);
--> statement-breakpoint
CREATE TABLE "sales_payment_products" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"sales_payment_id" uuid NOT NULL,
"product_id" uuid NOT NULL,
"quantity" numeric(18, 4) NOT NULL,
"price" numeric(18, 4) NOT NULL
);
--> statement-breakpoint
ALTER TABLE "packing_slip_images" ADD CONSTRAINT "packing_slip_images_packing_slip_id_packing_slips_id_fk" FOREIGN KEY ("packing_slip_id") REFERENCES "public"."packing_slips"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sales_invoice_images" ADD CONSTRAINT "sales_invoice_images_sales_invoice_id_sales_invoices_id_fk" FOREIGN KEY ("sales_invoice_id") REFERENCES "public"."sales_invoices"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sales_payment_products" ADD CONSTRAINT "sales_payment_products_sales_payment_id_sales_payments_id_fk" FOREIGN KEY ("sales_payment_id") REFERENCES "public"."sales_payments"("id") ON DELETE cascade ON UPDATE no action;
+16
View File
@@ -0,0 +1,16 @@
ALTER TABLE "packing_slips" DROP COLUMN IF EXISTS "sales_person_id";--> statement-breakpoint
ALTER TABLE "packing_slips" DROP COLUMN IF EXISTS "branch_id";--> statement-breakpoint
ALTER TABLE "packing_slips" DROP COLUMN IF EXISTS "division_id";--> statement-breakpoint
DROP TABLE IF EXISTS "packing_slip_images";--> statement-breakpoint
ALTER TABLE "sales_invoices" DROP COLUMN IF EXISTS "address";--> statement-breakpoint
ALTER TABLE "sales_invoices" DROP COLUMN IF EXISTS "latitude";--> statement-breakpoint
ALTER TABLE "sales_invoices" DROP COLUMN IF EXISTS "longitude";--> statement-breakpoint
DROP TABLE IF EXISTS "sales_invoice_images";--> statement-breakpoint
ALTER TABLE "sales_payments" DROP COLUMN IF EXISTS "sales_person_id";--> statement-breakpoint
ALTER TABLE "sales_payments" DROP COLUMN IF EXISTS "branch_id";--> statement-breakpoint
ALTER TABLE "sales_payments" DROP COLUMN IF EXISTS "division_id";--> statement-breakpoint
ALTER TABLE "sales_payments" DROP COLUMN IF EXISTS "customer_id";--> statement-breakpoint
ALTER TABLE "sales_payments" DROP COLUMN IF EXISTS "address";--> statement-breakpoint
ALTER TABLE "sales_payments" DROP COLUMN IF EXISTS "latitude";--> statement-breakpoint
ALTER TABLE "sales_payments" DROP COLUMN IF EXISTS "longitude";--> statement-breakpoint
DROP TABLE IF EXISTS "sales_payment_products";
+112
View File
@@ -0,0 +1,112 @@
CREATE TABLE "company_settings" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"cycle_start_date" bigint NOT NULL,
"status" text DEFAULT 'draft' NOT NULL,
"created_at" bigint NOT NULL,
"updated_at" bigint NOT NULL,
"created_by" uuid NOT NULL,
"updated_by" uuid NOT NULL
);
--> statement-breakpoint
CREATE TABLE "cycles" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"employee_id" uuid NOT NULL,
"purpose" text NOT NULL,
"cycle_number" integer NOT NULL,
"status" text DEFAULT 'draft' NOT NULL,
"created_at" bigint NOT NULL,
"updated_at" bigint NOT NULL,
"created_by" uuid NOT NULL,
"updated_by" uuid NOT NULL
);
--> statement-breakpoint
CREATE TABLE "cycle_weekdays" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"cycle_id" uuid NOT NULL,
"weekday" text NOT NULL,
"start_branch_id" uuid NOT NULL,
"end_branch_id" uuid NOT NULL,
"route_geometry" jsonb NOT NULL
);
--> statement-breakpoint
CREATE TABLE "cycle_destinations" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"cycle_weekday_id" uuid NOT NULL,
"customer_id" uuid NOT NULL,
"sort_order" integer NOT NULL
);
--> statement-breakpoint
CREATE TABLE "plans" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"employee_id" uuid NOT NULL,
"purpose" text NOT NULL,
"date" bigint NOT NULL,
"start_branch_id" uuid NOT NULL,
"end_branch_id" uuid NOT NULL,
"route_geometry" jsonb NOT NULL,
"status" text DEFAULT 'draft' NOT NULL,
"created_at" bigint NOT NULL,
"updated_at" bigint NOT NULL,
"created_by" uuid NOT NULL,
"updated_by" uuid NOT NULL
);
--> statement-breakpoint
CREATE TABLE "plan_destinations" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"plan_id" uuid NOT NULL,
"customer_id" uuid NOT NULL,
"sort_order" integer NOT NULL
);
--> statement-breakpoint
CREATE TABLE "plan_invoices" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"plan_id" uuid NOT NULL,
"invoice_id" uuid NOT NULL
);
--> statement-breakpoint
CREATE TABLE "plan_packing_slips" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"plan_id" uuid NOT NULL,
"packing_slip_id" uuid NOT NULL
);
--> statement-breakpoint
ALTER TABLE "company_settings" ADD CONSTRAINT "company_settings_created_by_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "company_settings" ADD CONSTRAINT "company_settings_updated_by_users_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "cycles" ADD CONSTRAINT "cycles_employee_id_employees_id_fk" FOREIGN KEY ("employee_id") REFERENCES "public"."employees"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "cycles" ADD CONSTRAINT "cycles_created_by_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "cycles" ADD CONSTRAINT "cycles_updated_by_users_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "cycle_weekdays" ADD CONSTRAINT "cycle_weekdays_cycle_id_cycles_id_fk" FOREIGN KEY ("cycle_id") REFERENCES "public"."cycles"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "cycle_weekdays" ADD CONSTRAINT "cycle_weekdays_start_branch_id_branches_id_fk" FOREIGN KEY ("start_branch_id") REFERENCES "public"."branches"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "cycle_weekdays" ADD CONSTRAINT "cycle_weekdays_end_branch_id_branches_id_fk" FOREIGN KEY ("end_branch_id") REFERENCES "public"."branches"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "cycle_destinations" ADD CONSTRAINT "cycle_destinations_cycle_weekday_id_cycle_weekdays_id_fk" FOREIGN KEY ("cycle_weekday_id") REFERENCES "public"."cycle_weekdays"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "cycle_destinations" ADD CONSTRAINT "cycle_destinations_customer_id_customers_id_fk" FOREIGN KEY ("customer_id") REFERENCES "public"."customers"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "plans" ADD CONSTRAINT "plans_employee_id_employees_id_fk" FOREIGN KEY ("employee_id") REFERENCES "public"."employees"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "plans" ADD CONSTRAINT "plans_start_branch_id_branches_id_fk" FOREIGN KEY ("start_branch_id") REFERENCES "public"."branches"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "plans" ADD CONSTRAINT "plans_end_branch_id_branches_id_fk" FOREIGN KEY ("end_branch_id") REFERENCES "public"."branches"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "plans" ADD CONSTRAINT "plans_created_by_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "plans" ADD CONSTRAINT "plans_updated_by_users_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "plan_destinations" ADD CONSTRAINT "plan_destinations_plan_id_plans_id_fk" FOREIGN KEY ("plan_id") REFERENCES "public"."plans"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "plan_destinations" ADD CONSTRAINT "plan_destinations_customer_id_customers_id_fk" FOREIGN KEY ("customer_id") REFERENCES "public"."customers"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "plan_invoices" ADD CONSTRAINT "plan_invoices_plan_id_plans_id_fk" FOREIGN KEY ("plan_id") REFERENCES "public"."plans"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "plan_invoices" ADD CONSTRAINT "plan_invoices_invoice_id_sales_invoices_id_fk" FOREIGN KEY ("invoice_id") REFERENCES "public"."sales_invoices"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "plan_packing_slips" ADD CONSTRAINT "plan_packing_slips_plan_id_plans_id_fk" FOREIGN KEY ("plan_id") REFERENCES "public"."plans"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "plan_packing_slips" ADD CONSTRAINT "plan_packing_slips_packing_slip_id_packing_slips_id_fk" FOREIGN KEY ("packing_slip_id") REFERENCES "public"."packing_slips"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "cycles_employee_purpose_number_live_unique" ON "cycles" USING btree ("employee_id","purpose","cycle_number") WHERE "status" <> 'archived';--> statement-breakpoint
CREATE INDEX "cycles_employee_id_idx" ON "cycles" USING btree ("employee_id");--> statement-breakpoint
CREATE UNIQUE INDEX "cycle_weekdays_cycle_weekday_unique" ON "cycle_weekdays" USING btree ("cycle_id","weekday");--> statement-breakpoint
CREATE INDEX "cycle_weekdays_cycle_id_idx" ON "cycle_weekdays" USING btree ("cycle_id");--> statement-breakpoint
CREATE INDEX "cycle_destinations_weekday_id_idx" ON "cycle_destinations" USING btree ("cycle_weekday_id");--> statement-breakpoint
CREATE UNIQUE INDEX "plans_employee_purpose_date_live_unique" ON "plans" USING btree ("employee_id","purpose","date") WHERE "status" <> 'archived';--> statement-breakpoint
CREATE INDEX "plans_employee_id_idx" ON "plans" USING btree ("employee_id");--> statement-breakpoint
CREATE UNIQUE INDEX "plan_destinations_plan_customer_unique" ON "plan_destinations" USING btree ("plan_id","customer_id");--> statement-breakpoint
CREATE INDEX "plan_destinations_plan_id_idx" ON "plan_destinations" USING btree ("plan_id");--> statement-breakpoint
CREATE UNIQUE INDEX "plan_invoices_plan_invoice_unique" ON "plan_invoices" USING btree ("plan_id","invoice_id");--> statement-breakpoint
CREATE INDEX "plan_invoices_plan_id_idx" ON "plan_invoices" USING btree ("plan_id");--> statement-breakpoint
CREATE UNIQUE INDEX "plan_packing_slips_plan_slip_unique" ON "plan_packing_slips" USING btree ("plan_id","packing_slip_id");--> statement-breakpoint
CREATE INDEX "plan_packing_slips_plan_id_idx" ON "plan_packing_slips" USING btree ("plan_id");--> statement-breakpoint
INSERT INTO "privilege_keys" ("code", "label", "sort_order") VALUES
('CONFIGURATION.SETTING', 'Company settings', 13),
('SALES.CYCLE', 'Sales cycles', 14),
('SALES.PLAN', 'Sales plans', 15),
('LOGISTICS.CYCLE', 'Logistics cycles', 16),
('LOGISTICS.PLAN', 'Logistics plans', 17);
+21
View File
@@ -0,0 +1,21 @@
ALTER TABLE "users" ADD COLUMN "status" text DEFAULT 'draft' NOT NULL;
--> statement-breakpoint
ALTER TABLE "users" ADD COLUMN "created_by" uuid;
--> statement-breakpoint
ALTER TABLE "users" ADD COLUMN "updated_by" uuid;
--> statement-breakpoint
UPDATE "users" SET "status" = 'active', "created_by" = "id", "updated_by" = "id";
--> statement-breakpoint
ALTER TABLE "users" ALTER COLUMN "created_by" SET NOT NULL;
--> statement-breakpoint
ALTER TABLE "users" ALTER COLUMN "updated_by" SET NOT NULL;
--> statement-breakpoint
ALTER TABLE "users" ADD CONSTRAINT "users_created_by_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "users" ADD CONSTRAINT "users_updated_by_users_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "employees" ADD COLUMN "user_id" uuid;
--> statement-breakpoint
ALTER TABLE "employees" ADD CONSTRAINT "employees_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;
--> statement-breakpoint
CREATE UNIQUE INDEX "employees_user_id_unique" ON "employees" USING btree ("user_id");
+21
View File
@@ -0,0 +1,21 @@
CREATE TABLE "report_bookmarks" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"group_name" text NOT NULL,
"unique_name" text NOT NULL,
"label" text NOT NULL,
"type" text NOT NULL,
"applied" boolean DEFAULT false NOT NULL,
"configuration" jsonb NOT NULL,
"status" text DEFAULT 'draft' NOT NULL,
"created_at" bigint NOT NULL,
"updated_at" bigint NOT NULL,
"created_by" uuid NOT NULL,
"updated_by" uuid NOT NULL
);
--> statement-breakpoint
ALTER TABLE "report_bookmarks" ADD CONSTRAINT "report_bookmarks_created_by_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "report_bookmarks" ADD CONSTRAINT "report_bookmarks_updated_by_users_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "report_bookmarks_owner_report_type_applied_unique" ON "report_bookmarks" USING btree ("created_by","group_name","unique_name","type") WHERE "applied" = true;--> statement-breakpoint
INSERT INTO "privilege_keys" ("code", "label", "sort_order") VALUES
('SALES.REPORT', 'Sales reports', 18),
('LOGISTICS.REPORT', 'Logistics reports', 19);
@@ -0,0 +1,4 @@
ALTER TABLE "sales_invoices" ADD COLUMN "address" text DEFAULT '' NOT NULL;--> statement-breakpoint
ALTER TABLE "sales_invoices" ALTER COLUMN "address" DROP DEFAULT;--> statement-breakpoint
ALTER TABLE "sales_invoices" ADD COLUMN "latitude" double precision;--> statement-breakpoint
ALTER TABLE "sales_invoices" ADD COLUMN "longitude" double precision;
@@ -0,0 +1,94 @@
ALTER TABLE "company_settings" ADD COLUMN "check_in_radius_meters" integer DEFAULT 100 NOT NULL;
--> statement-breakpoint
CREATE TABLE "attendances" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"employee_id" uuid NOT NULL,
"branch_id" uuid NOT NULL,
"date" bigint NOT NULL,
"check_in_at" bigint NOT NULL,
"check_in_method" text NOT NULL,
"check_in_latitude" double precision NOT NULL,
"check_in_longitude" double precision NOT NULL,
"check_in_photo_url" text,
"check_in_distance_meters" integer,
"check_out_at" bigint,
"check_out_method" text,
"check_out_latitude" double precision,
"check_out_longitude" double precision,
"check_out_photo_url" text,
"check_out_distance_meters" integer,
"status" text DEFAULT 'draft' NOT NULL,
"created_at" bigint NOT NULL,
"updated_at" bigint NOT NULL,
"created_by" uuid NOT NULL,
"updated_by" uuid NOT NULL
);
--> statement-breakpoint
CREATE TABLE "visits" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"employee_id" uuid NOT NULL,
"customer_id" uuid NOT NULL,
"attendance_id" uuid,
"plan_id" uuid,
"plan_destination_id" uuid,
"date" bigint NOT NULL,
"check_in_at" bigint NOT NULL,
"check_in_method" text NOT NULL,
"check_in_latitude" double precision NOT NULL,
"check_in_longitude" double precision NOT NULL,
"check_in_photo_url" text,
"check_in_distance_meters" integer,
"check_out_at" bigint,
"check_out_method" text,
"check_out_latitude" double precision,
"check_out_longitude" double precision,
"check_out_photo_url" text,
"check_out_distance_meters" integer,
"status" text DEFAULT 'draft' NOT NULL,
"created_at" bigint NOT NULL,
"updated_at" bigint NOT NULL,
"created_by" uuid NOT NULL,
"updated_by" uuid NOT NULL
);
--> statement-breakpoint
ALTER TABLE "attendances" ADD CONSTRAINT "attendances_employee_id_employees_id_fk" FOREIGN KEY ("employee_id") REFERENCES "public"."employees"("id") ON DELETE restrict ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "attendances" ADD CONSTRAINT "attendances_branch_id_branches_id_fk" FOREIGN KEY ("branch_id") REFERENCES "public"."branches"("id") ON DELETE restrict ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "attendances" ADD CONSTRAINT "attendances_created_by_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "attendances" ADD CONSTRAINT "attendances_updated_by_users_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "visits" ADD CONSTRAINT "visits_employee_id_employees_id_fk" FOREIGN KEY ("employee_id") REFERENCES "public"."employees"("id") ON DELETE restrict ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "visits" ADD CONSTRAINT "visits_customer_id_customers_id_fk" FOREIGN KEY ("customer_id") REFERENCES "public"."customers"("id") ON DELETE restrict ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "visits" ADD CONSTRAINT "visits_attendance_id_attendances_id_fk" FOREIGN KEY ("attendance_id") REFERENCES "public"."attendances"("id") ON DELETE set null ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "visits" ADD CONSTRAINT "visits_plan_id_plans_id_fk" FOREIGN KEY ("plan_id") REFERENCES "public"."plans"("id") ON DELETE set null ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "visits" ADD CONSTRAINT "visits_plan_destination_id_plan_destinations_id_fk" FOREIGN KEY ("plan_destination_id") REFERENCES "public"."plan_destinations"("id") ON DELETE set null ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "visits" ADD CONSTRAINT "visits_created_by_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "visits" ADD CONSTRAINT "visits_updated_by_users_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;
--> statement-breakpoint
CREATE UNIQUE INDEX "attendances_employee_date_live_unique" ON "attendances" USING btree ("employee_id","date") WHERE "attendances"."status" <> 'archived';
--> statement-breakpoint
CREATE UNIQUE INDEX "attendances_employee_open_unique" ON "attendances" USING btree ("employee_id") WHERE "attendances"."check_out_at" IS NULL AND "attendances"."status" <> 'archived';
--> statement-breakpoint
CREATE INDEX "attendances_employee_id_idx" ON "attendances" USING btree ("employee_id");
--> statement-breakpoint
CREATE INDEX "attendances_branch_id_idx" ON "attendances" USING btree ("branch_id");
--> statement-breakpoint
CREATE UNIQUE INDEX "visits_employee_open_unique" ON "visits" USING btree ("employee_id") WHERE "visits"."check_out_at" IS NULL AND "visits"."status" <> 'archived';
--> statement-breakpoint
CREATE INDEX "visits_employee_id_idx" ON "visits" USING btree ("employee_id");
--> statement-breakpoint
CREATE INDEX "visits_customer_id_idx" ON "visits" USING btree ("customer_id");
--> statement-breakpoint
CREATE INDEX "visits_attendance_id_idx" ON "visits" USING btree ("attendance_id");
--> statement-breakpoint
INSERT INTO "privilege_keys" ("code", "label", "sort_order") VALUES
('FIELD.ATTENDANCE', 'Branch attendance', 18),
('FIELD.VISIT', 'Customer visits', 19);
@@ -0,0 +1,51 @@
-- Rename existing privilege_keys to Group.Parent.Module[.Submodule] hierarchy
UPDATE "privilege_keys" SET "code" = 'ADMIN.SETTINGS.USER.PRIVILEGES', "label" = 'Privileges', "sort_order" = 101 WHERE "code" = 'PRIVILEGES';
--> statement-breakpoint
UPDATE "privilege_keys" SET "code" = 'ADMIN.SETTINGS.USER.USERS', "label" = 'Users', "sort_order" = 102 WHERE "code" = 'USERS';
--> statement-breakpoint
UPDATE "privilege_keys" SET "code" = 'ADMIN.SETTINGS.DATA.DIVISION', "label" = 'Divisions', "sort_order" = 103 WHERE "code" = 'CONFIGURATION.DIVISION';
--> statement-breakpoint
UPDATE "privilege_keys" SET "code" = 'ADMIN.SETTINGS.DATA.BRANCH', "label" = 'Branches', "sort_order" = 104 WHERE "code" = 'CONFIGURATION.BRANCH';
--> statement-breakpoint
UPDATE "privilege_keys" SET "code" = 'ADMIN.SETTINGS.DATA.CUSTOMER', "label" = 'Customers', "sort_order" = 105 WHERE "code" = 'CONFIGURATION.CUSTOMER';
--> statement-breakpoint
UPDATE "privilege_keys" SET "code" = 'ADMIN.SETTINGS.DATA.PRODUCT', "label" = 'Products', "sort_order" = 106 WHERE "code" = 'CONFIGURATION.PRODUCT';
--> statement-breakpoint
UPDATE "privilege_keys" SET "code" = 'ADMIN.SETTINGS.DATA.SETTING', "label" = 'Company settings', "sort_order" = 107 WHERE "code" = 'CONFIGURATION.SETTING';
--> statement-breakpoint
UPDATE "privilege_keys" SET "code" = 'ADMIN.SALES.DATA.EMPLOYEE', "label" = 'Employees', "sort_order" = 111 WHERE "code" = 'CONFIGURATION.EMPLOYEE';
--> statement-breakpoint
UPDATE "privilege_keys" SET "code" = 'ADMIN.SALES.DATA.CYCLE', "label" = 'Sales cycles', "sort_order" = 112 WHERE "code" = 'SALES.CYCLE';
--> statement-breakpoint
UPDATE "privilege_keys" SET "code" = 'ADMIN.SALES.ACTIVITIES.REQUEST', "label" = 'Sales requests', "sort_order" = 113 WHERE "code" = 'SALES.REQUEST';
--> statement-breakpoint
UPDATE "privilege_keys" SET "code" = 'ADMIN.SALES.ACTIVITIES.ORDER', "label" = 'Sales orders', "sort_order" = 114 WHERE "code" = 'SALES.ORDER';
--> statement-breakpoint
UPDATE "privilege_keys" SET "code" = 'ADMIN.SALES.ACTIVITIES.INVOICE', "label" = 'Sales invoices', "sort_order" = 115 WHERE "code" = 'SALES.INVOICE';
--> statement-breakpoint
UPDATE "privilege_keys" SET "code" = 'ADMIN.SALES.ACTIVITIES.PAYMENT', "label" = 'Sales payments', "sort_order" = 116 WHERE "code" = 'SALES.PAYMENT';
--> statement-breakpoint
UPDATE "privilege_keys" SET "code" = 'ADMIN.SALES.ACTIVITIES.PLAN', "label" = 'Sales plans', "sort_order" = 117 WHERE "code" = 'SALES.PLAN';
--> statement-breakpoint
UPDATE "privilege_keys" SET "code" = 'ADMIN.SALES.REPORT', "label" = 'Sales reports', "sort_order" = 118 WHERE "code" = 'SALES.REPORT';
--> statement-breakpoint
UPDATE "privilege_keys" SET "code" = 'ADMIN.LOGISTICS.ACTIVITIES.PACKING_SLIP', "label" = 'Packing slips', "sort_order" = 121 WHERE "code" = 'SALES.PACKING_SLIP';
--> statement-breakpoint
UPDATE "privilege_keys" SET "code" = 'ADMIN.LOGISTICS.DATA.CYCLE', "label" = 'Logistics cycles', "sort_order" = 122 WHERE "code" = 'LOGISTICS.CYCLE';
--> statement-breakpoint
UPDATE "privilege_keys" SET "code" = 'ADMIN.LOGISTICS.ACTIVITIES.PLAN', "label" = 'Logistics plans', "sort_order" = 123 WHERE "code" = 'LOGISTICS.PLAN';
--> statement-breakpoint
UPDATE "privilege_keys" SET "code" = 'ADMIN.LOGISTICS.REPORT', "label" = 'Logistics reports', "sort_order" = 124 WHERE "code" = 'LOGISTICS.REPORT';
--> statement-breakpoint
UPDATE "privilege_keys" SET "code" = 'MOBILE.SALES.PLAN.ATTENDANCE', "label" = 'Branch attendance', "sort_order" = 201 WHERE "code" = 'FIELD.ATTENDANCE';
--> statement-breakpoint
UPDATE "privilege_keys" SET "code" = 'MOBILE.SALES.VISIT', "label" = 'Customer visits', "sort_order" = 202 WHERE "code" = 'FIELD.VISIT';
--> statement-breakpoint
INSERT INTO "privilege_keys" ("code", "label", "sort_order") VALUES
('MOBILE.SALES.PLAN', 'Sales plans (mobile)', 203),
('MOBILE.SALES.REQUEST', 'Sales requests (mobile)', 204),
('MOBILE.SALES.ORDER', 'Sales orders (mobile)', 205),
('MOBILE.SALES.INVOICE', 'Sales invoices (mobile)', 206),
('MOBILE.SALES.PAYMENT', 'Sales payments (mobile)', 207),
('MOBILE.SALES.CUSTOMER', 'Customers (mobile)', 208),
('MOBILE.LOGISTICS.PLAN', 'Logistics plans (mobile)', 209);
+42
View File
@@ -0,0 +1,42 @@
ALTER TABLE "company_settings" ADD COLUMN "gps_interval_seconds" integer DEFAULT 5 NOT NULL;
--> statement-breakpoint
ALTER TABLE "company_settings" ADD COLUMN "checkout_warning_radius_meters" integer DEFAULT 200 NOT NULL;
--> statement-breakpoint
CREATE TABLE "timeline_footprints" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"employee_id" uuid NOT NULL,
"latitude" double precision NOT NULL,
"longitude" double precision NOT NULL,
"recorded_at" bigint NOT NULL
);
--> statement-breakpoint
CREATE TABLE "timeline_activities" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"employee_id" uuid NOT NULL,
"customer_id" uuid,
"visit_id" uuid,
"type" text NOT NULL,
"source_type" text NOT NULL,
"source_id" uuid NOT NULL,
"latitude" double precision NOT NULL,
"longitude" double precision NOT NULL,
"recorded_at" bigint NOT NULL
);
--> statement-breakpoint
ALTER TABLE "timeline_footprints" ADD CONSTRAINT "timeline_footprints_employee_id_employees_id_fk" FOREIGN KEY ("employee_id") REFERENCES "public"."employees"("id") ON DELETE restrict ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "timeline_activities" ADD CONSTRAINT "timeline_activities_employee_id_employees_id_fk" FOREIGN KEY ("employee_id") REFERENCES "public"."employees"("id") ON DELETE restrict ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "timeline_activities" ADD CONSTRAINT "timeline_activities_customer_id_customers_id_fk" FOREIGN KEY ("customer_id") REFERENCES "public"."customers"("id") ON DELETE set null ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "timeline_activities" ADD CONSTRAINT "timeline_activities_visit_id_visits_id_fk" FOREIGN KEY ("visit_id") REFERENCES "public"."visits"("id") ON DELETE set null ON UPDATE no action;
--> statement-breakpoint
CREATE INDEX "timeline_footprints_employee_recorded_idx" ON "timeline_footprints" USING btree ("employee_id","recorded_at");
--> statement-breakpoint
CREATE INDEX "timeline_activities_employee_recorded_idx" ON "timeline_activities" USING btree ("employee_id","recorded_at");
--> statement-breakpoint
CREATE INDEX "timeline_activities_visit_id_idx" ON "timeline_activities" USING btree ("visit_id");
--> statement-breakpoint
INSERT INTO "privilege_keys" ("code", "label", "sort_order") VALUES
('ADMIN.SALES.ACTIVITIES.TIMELINE', 'Sales timeline', 119),
('MOBILE.SALES.TIMELINE', 'Sales timeline (mobile)', 210);
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+84
View File
@@ -43,6 +43,90 @@
"when": 1787549883658,
"tag": "0005_past_vengeance",
"breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1787554000000,
"tag": "0006_customers",
"breakpoints": true
},
{
"idx": 7,
"version": "7",
"when": 1787555000000,
"tag": "0007_employees",
"breakpoints": true
},
{
"idx": 8,
"version": "7",
"when": 1787556000000,
"tag": "0008_products",
"breakpoints": true
},
{
"idx": 9,
"version": "7",
"when": 1787557000000,
"tag": "0009_sales",
"breakpoints": true
},
{
"idx": 10,
"version": "7",
"when": 1787558000000,
"tag": "0010_phase2_shape",
"breakpoints": true
},
{
"idx": 11,
"version": "7",
"when": 1787559000000,
"tag": "0011_field",
"breakpoints": true
},
{
"idx": 12,
"version": "7",
"when": 1787560000000,
"tag": "0012_users_primary",
"breakpoints": true
},
{
"idx": 13,
"version": "7",
"when": 1787561000000,
"tag": "0013_reports",
"breakpoints": true
},
{
"idx": 14,
"version": "7",
"when": 1787562000000,
"tag": "0014_sales_invoice_location",
"breakpoints": true
},
{
"idx": 15,
"version": "7",
"when": 1787563000000,
"tag": "0015_field_check_in",
"breakpoints": true
},
{
"idx": 16,
"version": "7",
"when": 1787564000000,
"tag": "0016_privilege_key_hierarchy",
"breakpoints": true
},
{
"idx": 17,
"version": "7",
"when": 1787565000000,
"tag": "0017_timeline",
"breakpoints": true
}
]
}
+8
View File
@@ -0,0 +1,8 @@
site_name: 'Concourse CI'
nav:
- Home: index.md
- Android APK Build: android-apk-build.md
plugins:
- techdocs-core
+6
View File
@@ -6,6 +6,9 @@ import loadEnv from './config/env';
import { DatabaseModule } from './database/database.module';
import { AuthModule } from './modules/auth/auth.module';
import { ConfigurationModule } from './modules/configuration/configuration.module';
import { FieldModule } from './modules/field/field.module';
import { ReportsModule } from './modules/reports/reports.module';
import { SalesModule } from './modules/sales/sales.module';
import { PrivilegesModule } from './modules/privileges/privileges.module';
import { UsersModule } from './modules/users/users.module';
@@ -20,6 +23,9 @@ import { UsersModule } from './modules/users/users.module';
AuthModule,
PrivilegesModule,
ConfigurationModule,
SalesModule,
FieldModule,
ReportsModule,
],
controllers: [AppController],
providers: [AppService],
@@ -4,13 +4,22 @@ import type { PrivilegeAction } from '../../modules/privileges/privilege-action'
export const REQUIRE_PRIVILEGE_KEY = 'requirePrivilege';
export type RequirePrivilegeMeta = {
readonly key: string;
readonly keys: readonly string[];
readonly action: PrivilegeAction;
};
/** Marks a handler as requiring a privilege matrix cell to be true. */
export const RequirePrivilege = (key: string, action: PrivilegeAction) =>
function normalizePrivilegeKeys(
keys: string | readonly string[],
): readonly string[] {
return typeof keys === 'string' ? [keys] : keys;
}
/** Marks a handler as requiring one or more privilege matrix cells (OR). */
export const RequirePrivilege = (
keys: string | readonly string[],
action: PrivilegeAction,
) =>
SetMetadata(REQUIRE_PRIVILEGE_KEY, {
key,
keys: normalizePrivilegeKeys(keys),
action,
} satisfies RequirePrivilegeMeta);
+41 -13
View File
@@ -12,14 +12,14 @@ import {
import { PrivilegesGuard } from './privileges.guard';
describe('PrivilegesGuard', () => {
const checkPermission = jest.fn();
const checkAnyPermission = jest.fn();
const getAllAndOverride = jest.fn();
const reflector = {
getAllAndOverride,
} as unknown as Reflector;
const guard = new PrivilegesGuard(reflector, {
checkPermission,
checkAnyPermission,
} as never);
const user: AuthUser = {
@@ -46,26 +46,48 @@ describe('PrivilegesGuard', () => {
it('allows when no RequirePrivilege metadata', async () => {
getAllAndOverride.mockReturnValue(undefined);
await expect(guard.canActivate(createContext(user))).resolves.toBe(true);
expect(checkPermission).not.toHaveBeenCalled();
expect(checkAnyPermission).not.toHaveBeenCalled();
});
it('allows when permission value is true', async () => {
const meta: RequirePrivilegeMeta = { key: 'PRIVILEGES', action: 'view' };
it('allows when permission value is true for a single key', async () => {
const meta: RequirePrivilegeMeta = {
keys: ['ADMIN.SETTINGS.USER.PRIVILEGES'],
action: 'view',
};
getAllAndOverride.mockReturnValue(meta);
checkPermission.mockResolvedValue(true);
checkAnyPermission.mockResolvedValue(true);
await expect(guard.canActivate(createContext(user))).resolves.toBe(true);
expect(checkPermission).toHaveBeenCalledWith(
expect(checkAnyPermission).toHaveBeenCalledWith(
'user-1',
'PRIVILEGES',
['ADMIN.SETTINGS.USER.PRIVILEGES'],
'view',
);
});
it('allows when any key in the list is granted', async () => {
const meta: RequirePrivilegeMeta = {
keys: ['ADMIN.SALES.ACTIVITIES.PLAN', 'MOBILE.SALES.PLAN'],
action: 'view',
};
getAllAndOverride.mockReturnValue(meta);
checkAnyPermission.mockResolvedValue(true);
await expect(guard.canActivate(createContext(user))).resolves.toBe(true);
expect(checkAnyPermission).toHaveBeenCalledWith(
'user-1',
['ADMIN.SALES.ACTIVITIES.PLAN', 'MOBILE.SALES.PLAN'],
'view',
);
});
it('forbids when permission is false or missing', async () => {
const meta: RequirePrivilegeMeta = { key: 'PRIVILEGES', action: 'delete' };
const meta: RequirePrivilegeMeta = {
keys: ['ADMIN.SETTINGS.USER.PRIVILEGES'],
action: 'delete',
};
getAllAndOverride.mockReturnValue(meta);
checkPermission.mockResolvedValue(false);
checkAnyPermission.mockResolvedValue(false);
await expect(guard.canActivate(createContext(user))).rejects.toBeInstanceOf(
ForbiddenException,
@@ -73,17 +95,23 @@ describe('PrivilegesGuard', () => {
});
it('skips privilege lookup when user is superadmin', async () => {
const meta: RequirePrivilegeMeta = { key: 'PRIVILEGES', action: 'delete' };
const meta: RequirePrivilegeMeta = {
keys: ['ADMIN.SETTINGS.USER.PRIVILEGES'],
action: 'delete',
};
getAllAndOverride.mockReturnValue(meta);
await expect(
guard.canActivate(createContext({ ...user, isSuperadmin: true })),
).resolves.toBe(true);
expect(checkPermission).not.toHaveBeenCalled();
expect(checkAnyPermission).not.toHaveBeenCalled();
});
it('unauthorized when metadata present but no user', async () => {
const meta: RequirePrivilegeMeta = { key: 'PRIVILEGES', action: 'view' };
const meta: RequirePrivilegeMeta = {
keys: ['ADMIN.SETTINGS.USER.PRIVILEGES'],
action: 'view',
};
getAllAndOverride.mockReturnValue(meta);
await expect(guard.canActivate(createContext())).rejects.toBeInstanceOf(
+2 -2
View File
@@ -39,9 +39,9 @@ export class PrivilegesGuard implements CanActivate {
return true;
}
const allowed = await this.privilegesService.checkPermission(
const allowed = await this.privilegesService.checkAnyPermission(
user.id,
required.key,
required.keys,
required.action,
);
if (!allowed) {
+26
View File
@@ -22,3 +22,29 @@ export {
PAGINATION_DEFAULT_LIMIT,
PAGINATION_MAX_LIMIT,
} from './pagination.constants';
export {
CODE_RELATION_FIELDS,
DEFAULT_RELATION_FIELDS,
fallbackUserRelation,
pickCodeRelation,
pickDefaultRelation,
pickRelation,
pickUserRelation,
USER_RELATION_FIELDS,
type CodeRelation,
type DefaultRelation,
type UserRelation,
} from './relation-fields';
export {
CodeRelationDto,
DefaultRelationDto,
UserRelationDto,
} from './relation.dto';
export {
ORDER_TYPES,
toOrderClauses,
type ListOrderQuery,
type OrderDefault,
type OrderType,
} from './order-clause';
export { parseQueryIdList } from './parse-query-id-list';
@@ -0,0 +1,71 @@
import { BadRequestException } from '@nestjs/common';
import { asc, desc } from 'drizzle-orm';
import { integer, pgTable } from 'drizzle-orm/pg-core';
import { toOrderClauses } from './order-clause';
const sample = pgTable('sample', {
code: integer('code'),
name: integer('name'),
createdAt: integer('created_at'),
});
const columns = {
code: sample.code,
name: sample.name,
createdAt: sample.createdAt,
};
describe('toOrderClauses', () => {
it('uses default columns when orderBy is omitted', () => {
expect(
toOrderClauses(columns, {}, [{ column: 'code', type: 'ASC' }]),
).toEqual([asc(sample.code)]);
});
it('applies multiple defaults when orderBy is omitted', () => {
expect(
toOrderClauses(columns, {}, [
{ column: 'createdAt', type: 'ASC' },
{ column: 'code', type: 'ASC' },
]),
).toEqual([asc(sample.createdAt), asc(sample.code)]);
});
it('uses a single client column and DESC', () => {
expect(
toOrderClauses(columns, { orderBy: 'name', orderType: 'DESC' }, [
{ column: 'code', type: 'ASC' },
]),
).toEqual([desc(sample.name)]);
});
it('applies orderType to defaults when orderBy is omitted', () => {
expect(
toOrderClauses(columns, { orderType: 'DESC' }, [
{ column: 'code', type: 'ASC' },
]),
).toEqual([desc(sample.code)]);
});
it('rejects unknown orderBy without echoing the raw value as SQL', () => {
expect(() =>
toOrderClauses(columns, { orderBy: 'drop table' }, [{ column: 'code' }]),
).toThrow(BadRequestException);
try {
toOrderClauses(columns, { orderBy: 'drop table' }, [{ column: 'code' }]);
} catch (error) {
expect((error as BadRequestException).message).toContain(
'code, name, createdAt',
);
expect((error as BadRequestException).message).not.toContain(
'drop table',
);
}
});
it('rejects invalid orderType', () => {
expect(() =>
toOrderClauses(columns, { orderType: 'SIDEWAYS' }, [{ column: 'code' }]),
).toThrow(BadRequestException);
});
});
+57
View File
@@ -0,0 +1,57 @@
import { BadRequestException } from '@nestjs/common';
import { asc, desc, type SQL } from 'drizzle-orm';
export const ORDER_TYPES = ['ASC', 'DESC'] as const;
export type OrderType = (typeof ORDER_TYPES)[number];
export type ListOrderQuery = {
readonly orderBy?: string;
readonly orderType?: string;
};
export type OrderDefault = {
readonly column?: string;
readonly type?: OrderType;
};
export function toOrderClauses(
columns: Record<string, Parameters<typeof asc>[0]>,
query: ListOrderQuery,
defaults: readonly OrderDefault[],
): SQL[] {
if (query.orderBy) {
return [toSql(columns, query.orderBy, normalizeOrderType(query.orderType))];
}
const typeOverride =
query.orderType != null && query.orderType !== ''
? normalizeOrderType(query.orderType)
: undefined;
return defaults.map((entry) =>
toSql(columns, entry.column ?? '', typeOverride ?? entry.type ?? 'ASC'),
);
}
function toSql(
columns: Record<string, Parameters<typeof asc>[0]>,
column: string,
type: OrderType,
): SQL {
const selected = columns[column];
if (selected == null) {
throw new BadRequestException(
`Invalid orderBy. Allowed: ${Object.keys(columns).join(', ')}`,
);
}
return type === 'DESC' ? desc(selected) : asc(selected);
}
function normalizeOrderType(raw?: string): OrderType {
if (raw == null || raw === '') {
return 'ASC';
}
const normalized = raw.toUpperCase();
if (normalized === 'ASC' || normalized === 'DESC') {
return normalized;
}
throw new BadRequestException('Invalid orderType. Allowed: ASC, DESC');
}
@@ -1,5 +1,7 @@
import { Type } from 'class-transformer';
import { IsInt, IsOptional, Max, Min } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Transform, Type } from 'class-transformer';
import { IsIn, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
import { ORDER_TYPES } from './order-clause';
import { PAGINATION_MAX_LIMIT } from './pagination.constants';
export class PaginationQueryDto {
@@ -21,4 +23,19 @@ export class PaginationQueryDto {
@IsInt()
@Min(0)
offset?: number;
@ApiPropertyOptional({
description: 'Column to order by (resource response field name)',
})
@IsOptional()
@IsString()
orderBy?: string;
@ApiPropertyOptional({ enum: ORDER_TYPES, default: 'ASC' })
@IsOptional()
@Transform(({ value }: { value: unknown }) =>
typeof value === 'string' ? value.toUpperCase() : value,
)
@IsIn([...ORDER_TYPES])
orderType?: (typeof ORDER_TYPES)[number];
}
@@ -0,0 +1,20 @@
import { parseQueryIdList } from './parse-query-id-list';
describe('parseQueryIdList', () => {
it('splits comma-separated and array values', () => {
expect(parseQueryIdList('cus-1,cus-2')).toEqual(['cus-1', 'cus-2']);
expect(parseQueryIdList(['cus-1', 'cus-2'])).toEqual(['cus-1', 'cus-2']);
expect(parseQueryIdList(['cus-1,cus-2', 'cus-3'])).toEqual([
'cus-1',
'cus-2',
'cus-3',
]);
expect(parseQueryIdList('cus-1,cus-1,cus-2')).toEqual(['cus-1', 'cus-2']);
});
it('returns undefined for empty input', () => {
expect(parseQueryIdList(undefined)).toBeUndefined();
expect(parseQueryIdList('')).toBeUndefined();
expect(parseQueryIdList([])).toBeUndefined();
});
});
@@ -0,0 +1,11 @@
export function parseQueryIdList(value: unknown): string[] | undefined {
if (value == null || value === '') {
return undefined;
}
const items = Array.isArray(value) ? value : [value];
const ids = items
.flatMap((item) => String(item).split(','))
.map((item) => item.trim())
.filter(Boolean);
return ids.length > 0 ? [...new Set(ids)] : undefined;
}
@@ -0,0 +1,93 @@
import {
CODE_RELATION_FIELDS,
DEFAULT_RELATION_FIELDS,
pickCodeRelation,
pickDefaultRelation,
pickRelation,
pickUserRelation,
USER_RELATION_FIELDS,
} from './relation-fields';
describe('pickRelation', () => {
const catalog = {
id: 'div-1',
code: 'JKT',
name: 'Jakarta',
status: 'active',
extra: 'secret',
};
const user = {
id: 'user-1',
username: 'admin',
passwordHash: 'hashed',
};
it('picks default id, code, name fields', () => {
expect(pickRelation(catalog, DEFAULT_RELATION_FIELDS)).toEqual({
id: 'div-1',
code: 'JKT',
name: 'Jakarta',
});
});
it('picks a custom field list for users', () => {
expect(pickRelation(user, USER_RELATION_FIELDS)).toEqual({
id: 'user-1',
username: 'admin',
});
});
it('returns null for null or undefined sources', () => {
const missing: typeof catalog | null = null;
const unset: typeof catalog | undefined = undefined;
expect(
pickRelation<typeof catalog, (typeof DEFAULT_RELATION_FIELDS)[number]>(
missing,
DEFAULT_RELATION_FIELDS,
),
).toBeNull();
expect(
pickRelation<typeof catalog, (typeof DEFAULT_RELATION_FIELDS)[number]>(
unset,
DEFAULT_RELATION_FIELDS,
),
).toBeNull();
});
it('does not copy fields outside the list', () => {
const picked = pickRelation(catalog, DEFAULT_RELATION_FIELDS);
expect(picked).not.toHaveProperty('status');
expect(picked).not.toHaveProperty('extra');
});
});
describe('pickCodeRelation', () => {
it('picks id and code only', () => {
expect(
pickCodeRelation({
id: 'so-1',
code: 'SO-001',
}),
).toEqual({ id: 'so-1', code: 'SO-001' });
expect(CODE_RELATION_FIELDS).toEqual(['id', 'code']);
expect(pickCodeRelation(null)).toBeNull();
});
});
describe('pickDefaultRelation / pickUserRelation', () => {
it('maps catalog and user sources without leaking extra fields', () => {
expect(
pickDefaultRelation({
id: 'div-1',
code: 'JKT',
name: 'Jakarta',
}),
).toEqual({ id: 'div-1', code: 'JKT', name: 'Jakarta' });
expect(pickDefaultRelation(null)).toBeNull();
expect(pickUserRelation({ id: 'user-1', username: 'admin' })).toEqual({
id: 'user-1',
username: 'admin',
});
});
});
@@ -0,0 +1,61 @@
export const DEFAULT_RELATION_FIELDS = ['id', 'code', 'name'] as const;
export const USER_RELATION_FIELDS = ['id', 'username'] as const;
export const CODE_RELATION_FIELDS = ['id', 'code'] as const;
export type DefaultRelation = {
readonly id: string;
readonly code: string;
readonly name: string;
};
export type UserRelation = {
readonly id: string;
readonly username: string;
};
export type CodeRelation = {
readonly id: string;
readonly code: string;
};
export function pickRelation<T, K extends keyof NonNullable<T>>(
source: T | null | undefined,
fields: readonly K[],
): Pick<NonNullable<T>, K> | null {
if (source == null) {
return null;
}
const result = {} as Pick<NonNullable<T>, K>;
for (const field of fields) {
result[field] = source[field];
}
return result;
}
export function pickDefaultRelation(
source: DefaultRelation | null | undefined,
): DefaultRelation | null {
return pickRelation(source, DEFAULT_RELATION_FIELDS);
}
export function pickUserRelation(source: UserRelation): UserRelation {
return (
pickRelation(source, USER_RELATION_FIELDS) ?? {
id: source.id,
username: source.username,
}
);
}
export function pickCodeRelation(
source: CodeRelation | null | undefined,
): CodeRelation | null {
return pickRelation(source, CODE_RELATION_FIELDS);
}
export function fallbackUserRelation(
source: UserRelation | null | undefined,
fallbackId: string,
): UserRelation {
return pickUserRelation(source ?? { id: fallbackId, username: '' });
}
+28
View File
@@ -0,0 +1,28 @@
import { ApiProperty } from '@nestjs/swagger';
export class DefaultRelationDto {
@ApiProperty({ format: 'uuid' })
id!: string;
@ApiProperty()
code!: string;
@ApiProperty()
name!: string;
}
export class UserRelationDto {
@ApiProperty({ format: 'uuid' })
id!: string;
@ApiProperty()
username!: string;
}
export class CodeRelationDto {
@ApiProperty({ format: 'uuid' })
id!: string;
@ApiProperty()
code!: string;
}
@@ -92,8 +92,24 @@ describe('DateTime', () => {
expect(dt.value).toBe(Date.UTC(2026, 7, 20, 10, 0, 0, 0));
});
it('rejects date-only strings', () => {
expect(() => DateTime.create('2026-08-20')).toThrow(InvalidDateTimeError);
it('parses date-only strings as start of day in DEFAULT_TIMEZONE (GMT+7)', () => {
delete process.env.DEFAULT_TIMEZONE;
const dt = DateTime.create('2026-08-20');
expect(dt.value).toBe(Date.UTC(2026, 7, 19, 17, 0, 0, 0));
});
it('parses date-only strings using a custom DEFAULT_TIMEZONE', () => {
process.env.DEFAULT_TIMEZONE = 'UTC+0';
const dt = DateTime.create('2026-08-20');
expect(dt.value).toBe(Date.UTC(2026, 7, 20, 0, 0, 0, 0));
});
it('rejects invalid calendar dates on date-only strings', () => {
expect(() => DateTime.create('2026-02-30')).toThrow(InvalidDateTimeError);
});
it('rejects empty string', () => {
@@ -299,4 +315,82 @@ describe('DateTime', () => {
).toThrow();
});
});
describe('startOfDay', () => {
it('returns midnight of the same calendar day in DEFAULT_TIMEZONE', () => {
delete process.env.DEFAULT_TIMEZONE;
const dt = DateTime.create('2026-08-20T17:30:00+07:00');
const start = dt.startOfDay();
expect(start.value).toBe(Date.UTC(2026, 7, 19, 17, 0, 0, 0));
expect(start.equals(DateTime.create('2026-08-20'))).toBe(true);
});
it('does not mutate the original instant', () => {
const dt = DateTime.create('2026-08-20T10:00:00Z');
const before = dt.value;
dt.startOfDay();
expect(dt.value).toBe(before);
});
});
describe('weekdayName', () => {
it('returns monday through sunday in DEFAULT_TIMEZONE', () => {
delete process.env.DEFAULT_TIMEZONE;
expect(DateTime.create('2026-08-24').weekdayName()).toBe('monday');
expect(DateTime.create('2026-08-25').weekdayName()).toBe('tuesday');
expect(DateTime.create('2026-08-26').weekdayName()).toBe('wednesday');
expect(DateTime.create('2026-08-27').weekdayName()).toBe('thursday');
expect(DateTime.create('2026-08-28').weekdayName()).toBe('friday');
expect(DateTime.create('2026-08-29').weekdayName()).toBe('saturday');
expect(DateTime.create('2026-08-30').weekdayName()).toBe('sunday');
});
it('uses the calendar day in DEFAULT_TIMEZONE, not UTC', () => {
delete process.env.DEFAULT_TIMEZONE;
// 2026-08-24 00:30 GMT+7 is still Sunday UTC
const dt = DateTime.create('2026-08-24T00:30:00+07:00');
expect(dt.weekdayName()).toBe('monday');
});
});
describe('wholeWeeksSince', () => {
it('returns 0 on the epoch day and through the next 6 days', () => {
delete process.env.DEFAULT_TIMEZONE;
const epoch = DateTime.create('2026-01-05');
expect(DateTime.create('2026-01-05').wholeWeeksSince(epoch)).toBe(0);
expect(DateTime.create('2026-01-11').wholeWeeksSince(epoch)).toBe(0);
});
it('returns 1 at +7 days (next week)', () => {
delete process.env.DEFAULT_TIMEZONE;
const epoch = DateTime.create('2026-01-05');
expect(DateTime.create('2026-01-12').wholeWeeksSince(epoch)).toBe(1);
});
it('wraps so remainder 0 of a 1-based week index is the last cycle', () => {
delete process.env.DEFAULT_TIMEZONE;
const epoch = DateTime.create('2026-01-05');
const totalCycles = 3;
const week2 = DateTime.create('2026-01-19'); // wholeWeeks = 2
const cycleNumber = (week2.wholeWeeksSince(epoch) % totalCycles) + 1;
expect(week2.wholeWeeksSince(epoch)).toBe(2);
expect(cycleNumber).toBe(3);
});
it('returns a negative count when the date is before the epoch', () => {
delete process.env.DEFAULT_TIMEZONE;
const epoch = DateTime.create('2026-01-05');
expect(DateTime.create('2025-12-29').wholeWeeksSince(epoch)).toBe(-1);
});
});
});
@@ -9,6 +9,24 @@ const MAX_UNIX_MS = 8_640_000_000_000_000;
const ISO_DATETIME =
/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,9}))?(?:(Z)|([+-])(\d{2})(?::?(\d{2}))?)?$/i;
/** Calendar date (YYYY-MM-DD), interpreted as 00:00:00 in DEFAULT_TIMEZONE. */
const ISO_DATE = /^(\d{4})-(\d{2})-(\d{2})$/;
const WEEKDAY_NAMES = [
'sunday',
'monday',
'tuesday',
'wednesday',
'thursday',
'friday',
'saturday',
] as const;
const MS_PER_DAY = 86_400_000;
const MS_PER_WEEK = 7 * MS_PER_DAY;
export type WeekdayName = (typeof WEEKDAY_NAMES)[number];
/** Fixed offset forms: GMT+7, UTC+07:00, +7, +07:00, +0700, Z, UTC */
const TZ_OFFSET =
/^(?:(?:GMT|UTC)\s*)?([+-])(\d{1,2})(?::?(\d{2}))?$|^(?:Z|UTC|GMT)$/i;
@@ -149,6 +167,20 @@ export class DateTime {
}
const trimmed = raw.trim();
const dateOnly = ISO_DATE.exec(trimmed);
if (dateOnly) {
return DateTime.fromParts(
Number.parseInt(dateOnly[1], 10),
Number.parseInt(dateOnly[2], 10),
Number.parseInt(dateOnly[3], 10),
0,
0,
0,
0,
resolveDefaultOffsetMinutes(),
);
}
const match = ISO_DATETIME.exec(trimmed);
if (!match) {
throw new InvalidDateTimeError();
@@ -175,7 +207,7 @@ export class DateTime {
offsetMinutes = resolveDefaultOffsetMinutes();
}
const utcMs = utcMsFromParts(
return DateTime.fromParts(
year,
month,
day,
@@ -185,8 +217,6 @@ export class DateTime {
ms,
offsetMinutes,
);
return new DateTime(utcMs, DateTime.createToken);
}
static fromUnixMs(ms: number): DateTime {
@@ -217,6 +247,32 @@ export class DateTime {
return formatInOffset(this.unixMs, offsetMinutes);
}
startOfDay(): DateTime {
const offsetMinutes = resolveDefaultOffsetMinutes();
const shifted = new Date(this.unixMs + offsetMinutes * 60_000);
return DateTime.fromParts(
shifted.getUTCFullYear(),
shifted.getUTCMonth() + 1,
shifted.getUTCDate(),
0,
0,
0,
0,
offsetMinutes,
);
}
weekdayName(): WeekdayName {
const offsetMinutes = resolveDefaultOffsetMinutes();
const shifted = new Date(this.unixMs + offsetMinutes * 60_000);
return WEEKDAY_NAMES[shifted.getUTCDay()];
}
wholeWeeksSince(epoch: DateTime): number {
const diff = this.startOfDay().value - epoch.startOfDay().value;
return Math.trunc(diff / MS_PER_WEEK);
}
toString(): string {
return this.format();
}
@@ -224,4 +280,27 @@ export class DateTime {
toJSON(): number {
return this.unixMs;
}
private static fromParts(
year: number,
month: number,
day: number,
hour: number,
minute: number,
second: number,
ms: number,
offsetMinutes: number,
): DateTime {
const utcMs = utcMsFromParts(
year,
month,
day,
hour,
minute,
second,
ms,
offsetMinutes,
);
return new DateTime(utcMs, DateTime.createToken);
}
}
@@ -0,0 +1,96 @@
import { Decimal } from './decimal';
import { InvalidDecimalError } from './invalid-decimal.error';
describe('Decimal', () => {
describe('create', () => {
it('accepts canonical scale-4 strings', () => {
expect(Decimal.create('10.5000').value).toBe('10.5000');
expect(Decimal.create('0').value).toBe('0.0000');
expect(Decimal.create('0.5').value).toBe('0.5000');
});
it('trims surrounding whitespace', () => {
expect(Decimal.create(' 12.34 ').value).toBe('12.3400');
});
it('accepts integer-looking numbers at the HTTP edge', () => {
expect(Decimal.create(10).value).toBe('10.0000');
expect(Decimal.create(0).value).toBe('0.0000');
});
it('accepts a leading plus or minus', () => {
expect(Decimal.create('+2.5').value).toBe('2.5000');
expect(Decimal.create('-2.5').value).toBe('-2.5000');
});
it('rejects more than 4 fractional digits', () => {
expect(() => Decimal.create('1.23456')).toThrow(InvalidDecimalError);
});
it('rejects non-finite numbers and non-numeric strings', () => {
expect(() => Decimal.create(Number.NaN)).toThrow(InvalidDecimalError);
expect(() => Decimal.create(Number.POSITIVE_INFINITY)).toThrow(
InvalidDecimalError,
);
expect(() => Decimal.create('abc')).toThrow(InvalidDecimalError);
expect(() => Decimal.create('')).toThrow(InvalidDecimalError);
expect(() => Decimal.create(' ')).toThrow(InvalidDecimalError);
expect(() => Decimal.create('1e3')).toThrow(InvalidDecimalError);
});
it('rejects values that exceed precision 18', () => {
expect(() => Decimal.create('123456789012345.0000')).toThrow(
InvalidDecimalError,
);
});
it('does not echo raw input in the error message', () => {
expect(() => Decimal.create('secret-1.23')).toThrow('Invalid decimal');
try {
Decimal.create('secret-1.23');
} catch (error) {
expect((error as Error).message).not.toContain('secret-1.23');
}
});
});
describe('arithmetic', () => {
it('adds, subtracts, and multiplies at scale 4', () => {
const a = Decimal.create('2.5000');
const b = Decimal.create('1.2500');
expect(a.add(b).value).toBe('3.7500');
expect(a.subtract(b).value).toBe('1.2500');
expect(a.multiply(b).value).toBe('3.1250');
});
it('compares values', () => {
const a = Decimal.create('1.0000');
const b = Decimal.create('2.0000');
expect(a.compare(b)).toBe(-1);
expect(b.compare(a)).toBe(1);
expect(a.compare(Decimal.create('1'))).toBe(0);
expect(a.equals(Decimal.create('1.0000'))).toBe(true);
expect(a.equals(b)).toBe(false);
expect(Decimal.create('0').isZero()).toBe(true);
expect(Decimal.create('-1').isNegative()).toBe(true);
expect(Decimal.create('0.0001').isPositive()).toBe(true);
});
});
describe('serialization', () => {
it('toString and toJSON return the canonical string', () => {
const value = Decimal.create('9.1');
expect(value.toString()).toBe('9.1000');
expect(value.toJSON()).toBe('9.1000');
expect(JSON.stringify({ price: value })).toBe('{"price":"9.1000"}');
});
});
describe('construction', () => {
it('cannot be constructed with new Decimal()', () => {
expect(
() => new (Decimal as unknown as new (...args: unknown[]) => Decimal)(),
).toThrow(TypeError);
});
});
});
+145
View File
@@ -0,0 +1,145 @@
import { InvalidDecimalError } from './invalid-decimal.error';
export const DECIMAL_SCALE = 4;
export const DECIMAL_PRECISION = 18;
const DECIMAL_FACTOR = 10n ** BigInt(DECIMAL_SCALE);
const MAX_UNSCALED =
10n ** BigInt(DECIMAL_PRECISION) - 1n; /* 18 digits of unscaled integer */
const DECIMAL_PATTERN = /^[+-]?(?:\d+|\d+\.\d{1,4}|\.\d{1,4})$/;
export class Decimal {
private static readonly createToken = Symbol('Decimal.create');
private constructor(
private readonly unscaled: bigint,
token: symbol,
) {
if (token !== Decimal.createToken) {
throw new TypeError('Decimal can only be created via Decimal.create()');
}
Object.freeze(this);
}
/**
* Creates a Decimal from a string or an integer-looking number.
* Canonical scale is 4 (e.g. 10.5 → 10.5000). Precision is 18.
*/
static create(raw: string | number): Decimal {
const text = Decimal.normalizeRaw(raw);
if (!DECIMAL_PATTERN.test(text)) {
throw new InvalidDecimalError();
}
const negative = text.startsWith('-');
const unsigned =
text.startsWith('+') || text.startsWith('-') ? text.slice(1) : text;
const [wholePart, fractionPart = ''] = unsigned.split('.');
const whole = wholePart === '' ? '0' : wholePart;
const fraction = fractionPart.padEnd(DECIMAL_SCALE, '0');
const digits = `${whole}${fraction}`.replace(/^0+(?=\d)/, '');
let unscaled = BigInt(digits);
if (negative) {
unscaled = -unscaled;
}
if (unscaled > MAX_UNSCALED || unscaled < -MAX_UNSCALED) {
throw new InvalidDecimalError();
}
return new Decimal(unscaled, Decimal.createToken);
}
static zero(): Decimal {
return new Decimal(0n, Decimal.createToken);
}
get value(): string {
return this.format();
}
add(other: Decimal): Decimal {
return Decimal.fromUnscaled(this.unscaled + other.unscaled);
}
subtract(other: Decimal): Decimal {
return Decimal.fromUnscaled(this.unscaled - other.unscaled);
}
multiply(other: Decimal): Decimal {
const product = this.unscaled * other.unscaled;
const half = DECIMAL_FACTOR / 2n;
const remainder = product % DECIMAL_FACTOR;
let quotient = product / DECIMAL_FACTOR;
const absRemainder = remainder < 0n ? -remainder : remainder;
if (absRemainder >= half) {
quotient += product < 0n ? -1n : 1n;
}
return Decimal.fromUnscaled(quotient);
}
compare(other: Decimal): -1 | 0 | 1 {
if (this.unscaled < other.unscaled) {
return -1;
}
if (this.unscaled > other.unscaled) {
return 1;
}
return 0;
}
equals(other: Decimal): boolean {
return other instanceof Decimal && this.unscaled === other.unscaled;
}
isZero(): boolean {
return this.unscaled === 0n;
}
isNegative(): boolean {
return this.unscaled < 0n;
}
isPositive(): boolean {
return this.unscaled > 0n;
}
toString(): string {
return this.format();
}
toJSON(): string {
return this.format();
}
private format(): string {
const negative = this.unscaled < 0n;
const abs = negative ? -this.unscaled : this.unscaled;
const padded = abs.toString().padStart(DECIMAL_SCALE + 1, '0');
const whole = padded.slice(0, -DECIMAL_SCALE);
const fraction = padded.slice(-DECIMAL_SCALE);
return `${negative ? '-' : ''}${whole}.${fraction}`;
}
private static fromUnscaled(unscaled: bigint): Decimal {
if (unscaled > MAX_UNSCALED || unscaled < -MAX_UNSCALED) {
throw new InvalidDecimalError();
}
return new Decimal(unscaled, Decimal.createToken);
}
private static normalizeRaw(raw: string | number): string {
if (typeof raw === 'number') {
if (!Number.isInteger(raw) || !Number.isSafeInteger(raw)) {
throw new InvalidDecimalError();
}
return String(raw);
}
if (typeof raw !== 'string') {
throw new InvalidDecimalError();
}
const trimmed = raw.trim();
if (trimmed === '') {
throw new InvalidDecimalError();
}
return trimmed;
}
}
@@ -0,0 +1,6 @@
export class InvalidDecimalError extends Error {
constructor() {
super('Invalid decimal');
this.name = 'InvalidDecimalError';
}
}
+30
View File
@@ -17,6 +17,7 @@ describe('loadEnv', () => {
expect(env.REFRESH_TOKEN_EXPIRES_IN_MS).toBe(7 * 24 * 60 * 60 * 1000);
expect(env.BCRYPT_SALT_ROUNDS).toBe(10);
expect(env.DEFAULT_TIMEZONE).toBe('GMT+7');
expect(env.SKIP_GPS_VALIDATION).toBe(false);
});
it('throws when DATABASE_URL is missing', () => {
@@ -75,4 +76,33 @@ describe('loadEnv', () => {
}),
).toThrow('must match');
});
it('enables SKIP_GPS_VALIDATION outside production', () => {
const env = loadEnv({
...valid,
SKIP_GPS_VALIDATION: 'true',
NODE_ENV: 'development',
});
expect(env.SKIP_GPS_VALIDATION).toBe(true);
});
it('rejects SKIP_GPS_VALIDATION in production', () => {
expect(() =>
loadEnv({
...valid,
SKIP_GPS_VALIDATION: 'true',
NODE_ENV: 'production',
}),
).toThrow('cannot be enabled in production');
});
it('rejects invalid SKIP_GPS_VALIDATION values', () => {
expect(() =>
loadEnv({
...valid,
SKIP_GPS_VALIDATION: 'yes',
}),
).toThrow('must be true or false');
});
});
+29
View File
@@ -7,6 +7,7 @@ export type AppEnv = {
REFRESH_TOKEN_EXPIRES_IN_MS: number;
BCRYPT_SALT_ROUNDS: number;
DEFAULT_TIMEZONE: string;
SKIP_GPS_VALIDATION: boolean;
};
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
@@ -37,6 +38,24 @@ function requireSecret(name: string, value: string | undefined): string {
return secret;
}
function parseBoolean(
name: string,
value: string | undefined,
fallback: boolean,
): boolean {
if (value === undefined || value.trim() === '') {
return fallback;
}
const normalized = value.trim().toLowerCase();
if (normalized === 'true' || normalized === '1') {
return true;
}
if (normalized === 'false' || normalized === '0') {
return false;
}
throw new Error(`${name} must be true or false`);
}
function parsePositiveInt(
name: string,
value: string | undefined,
@@ -88,6 +107,15 @@ export function loadEnv(source: NodeJS.ProcessEnv = process.env): AppEnv {
FIFTEEN_MINUTES_MS,
);
const skipGpsValidation = parseBoolean(
'SKIP_GPS_VALIDATION',
source.SKIP_GPS_VALIDATION,
false,
);
if (source.NODE_ENV === 'production' && skipGpsValidation) {
throw new Error('SKIP_GPS_VALIDATION cannot be enabled in production');
}
return {
PORT: parsePositiveInt('PORT', source.PORT, 3000),
DATABASE_URL: requireString('DATABASE_URL', source.DATABASE_URL),
@@ -108,6 +136,7 @@ export function loadEnv(source: NodeJS.ProcessEnv = process.env): AppEnv {
10,
),
DEFAULT_TIMEZONE: source.DEFAULT_TIMEZONE?.trim() || 'GMT+7',
SKIP_GPS_VALIDATION: skipGpsValidation,
};
}
+37
View File
@@ -0,0 +1,37 @@
import { sql } from 'drizzle-orm';
import { bigint, index, pgTable, uniqueIndex, uuid } from 'drizzle-orm/pg-core';
import { branches } from './branches-table';
import { checkInColumns, checkOutColumns } from './checkpoint-columns';
import { employees } from './employees-table';
import { primaryEntityColumns } from './primary-entity-columns';
import { users } from './schema';
export const attendances = pgTable(
'attendances',
{
id: uuid('id').defaultRandom().notNull().primaryKey(),
employeeId: uuid('employee_id')
.notNull()
.references(() => employees.id, { onDelete: 'restrict' }),
branchId: uuid('branch_id')
.notNull()
.references(() => branches.id, { onDelete: 'restrict' }),
date: bigint('date', { mode: 'number' }).notNull(),
...checkInColumns,
...checkOutColumns,
...primaryEntityColumns(users),
},
(t) => [
uniqueIndex('attendances_employee_date_live_unique')
.on(t.employeeId, t.date)
.where(sql`${t.status} <> 'archived'`),
uniqueIndex('attendances_employee_open_unique')
.on(t.employeeId)
.where(sql`${t.checkOutAt} IS NULL AND ${t.status} <> 'archived'`),
index('attendances_employee_id_idx').on(t.employeeId),
index('attendances_branch_id_idx').on(t.branchId),
],
);
export type AttendanceRow = typeof attendances.$inferSelect;
export type NewAttendanceRow = typeof attendances.$inferInsert;
+19
View File
@@ -0,0 +1,19 @@
import { bigint, doublePrecision, integer, text } from 'drizzle-orm/pg-core';
export const checkInColumns = {
checkInAt: bigint('check_in_at', { mode: 'number' }).notNull(),
checkInMethod: text('check_in_method').notNull(),
checkInLatitude: doublePrecision('check_in_latitude').notNull(),
checkInLongitude: doublePrecision('check_in_longitude').notNull(),
checkInPhotoUrl: text('check_in_photo_url'),
checkInDistanceMeters: integer('check_in_distance_meters'),
};
export const checkOutColumns = {
checkOutAt: bigint('check_out_at', { mode: 'number' }),
checkOutMethod: text('check_out_method'),
checkOutLatitude: doublePrecision('check_out_latitude'),
checkOutLongitude: doublePrecision('check_out_longitude'),
checkOutPhotoUrl: text('check_out_photo_url'),
checkOutDistanceMeters: integer('check_out_distance_meters'),
};
+20
View File
@@ -0,0 +1,20 @@
import { bigint, integer, pgTable, uuid } from 'drizzle-orm/pg-core';
import { primaryEntityColumns } from './primary-entity-columns';
import { users } from './schema';
/**
* Company-wide operational settings (singleton aggregate).
*/
export const companySettings = pgTable('company_settings', {
id: uuid('id').defaultRandom().notNull().primaryKey(),
cycleStartDate: bigint('cycle_start_date', { mode: 'number' }).notNull(),
checkInRadiusMeters: integer('check_in_radius_meters').notNull().default(100),
gpsIntervalSeconds: integer('gps_interval_seconds').notNull().default(5),
checkoutWarningRadiusMeters: integer('checkout_warning_radius_meters')
.notNull()
.default(200),
...primaryEntityColumns(users),
});
export type CompanySettingsRow = typeof companySettings.$inferSelect;
export type NewCompanySettingsRow = typeof companySettings.$inferInsert;
+58
View File
@@ -0,0 +1,58 @@
import {
doublePrecision,
index,
pgTable,
text,
uniqueIndex,
uuid,
varchar,
} from 'drizzle-orm/pg-core';
import { primaryEntityColumns } from './primary-entity-columns';
import { users } from './schema';
/**
* Customers (primary aggregate).
* Kept in a separate module so Drizzle's table type stays resolvable.
*/
export const customers = pgTable(
'customers',
{
id: uuid('id').defaultRandom().notNull().primaryKey(),
code: varchar('code', { length: 16 }).notNull(),
name: varchar('name', { length: 64 }).notNull(),
phone: text('phone').notNull(),
address: text('address').notNull(),
latitude: doublePrecision('latitude'),
longitude: doublePrecision('longitude'),
nfcId: text('nfc_id'),
...primaryEntityColumns(users),
},
(t) => [
uniqueIndex('customers_code_unique').on(t.code),
uniqueIndex('customers_nfc_id_unique').on(t.nfcId),
],
);
/**
* Customer contacts (child rows). Cascade with the parent customer.
*/
export const customerContacts = pgTable(
'customer_contacts',
{
id: uuid('id').defaultRandom().notNull().primaryKey(),
customerId: uuid('customer_id')
.notNull()
.references(() => customers.id, { onDelete: 'cascade' }),
name: varchar('name', { length: 64 }).notNull(),
jobTitle: varchar('job_title', { length: 64 }),
phone: text('phone'),
mobilePhone: text('mobile_phone'),
notes: text('notes'),
},
(t) => [index('customer_contacts_customer_id_idx').on(t.customerId)],
);
export type CustomerRow = typeof customers.$inferSelect;
export type NewCustomerRow = typeof customers.$inferInsert;
export type CustomerContactRow = typeof customerContacts.$inferSelect;
export type NewCustomerContactRow = typeof customerContacts.$inferInsert;
+83
View File
@@ -0,0 +1,83 @@
import { sql } from 'drizzle-orm';
import {
index,
integer,
jsonb,
pgTable,
text,
uniqueIndex,
uuid,
} from 'drizzle-orm/pg-core';
import { branches } from './branches-table';
import { customers } from './customers-table';
import { employees } from './employees-table';
import { primaryEntityColumns } from './primary-entity-columns';
import { users } from './schema';
export type StoredRouteGeometry = {
readonly type: 'LineString';
readonly coordinates: readonly (readonly [number, number])[];
};
export const cycles = pgTable(
'cycles',
{
id: uuid('id').defaultRandom().notNull().primaryKey(),
employeeId: uuid('employee_id')
.notNull()
.references(() => employees.id, { onDelete: 'restrict' }),
purpose: text('purpose').notNull(),
cycleNumber: integer('cycle_number').notNull(),
...primaryEntityColumns(users),
},
(t) => [
uniqueIndex('cycles_employee_purpose_number_live_unique')
.on(t.employeeId, t.purpose, t.cycleNumber)
.where(sql`${t.status} <> 'archived'`),
index('cycles_employee_id_idx').on(t.employeeId),
],
);
export const cycleWeekdays = pgTable(
'cycle_weekdays',
{
id: uuid('id').defaultRandom().notNull().primaryKey(),
cycleId: uuid('cycle_id')
.notNull()
.references(() => cycles.id, { onDelete: 'cascade' }),
weekday: text('weekday').notNull(),
startBranchId: uuid('start_branch_id')
.notNull()
.references(() => branches.id, { onDelete: 'restrict' }),
endBranchId: uuid('end_branch_id')
.notNull()
.references(() => branches.id, { onDelete: 'restrict' }),
routeGeometry: jsonb('route_geometry')
.$type<StoredRouteGeometry>()
.notNull(),
},
(t) => [
uniqueIndex('cycle_weekdays_cycle_weekday_unique').on(t.cycleId, t.weekday),
index('cycle_weekdays_cycle_id_idx').on(t.cycleId),
],
);
export const cycleDestinations = pgTable(
'cycle_destinations',
{
id: uuid('id').defaultRandom().notNull().primaryKey(),
cycleWeekdayId: uuid('cycle_weekday_id')
.notNull()
.references(() => cycleWeekdays.id, { onDelete: 'cascade' }),
customerId: uuid('customer_id')
.notNull()
.references(() => customers.id, { onDelete: 'restrict' }),
sortOrder: integer('sort_order').notNull(),
},
(t) => [index('cycle_destinations_weekday_id_idx').on(t.cycleWeekdayId)],
);
export type CycleRow = typeof cycles.$inferSelect;
export type NewCycleRow = typeof cycles.$inferInsert;
export type CycleWeekdayRow = typeof cycleWeekdays.$inferSelect;
export type CycleDestinationRow = typeof cycleDestinations.$inferSelect;
+17
View File
@@ -0,0 +1,17 @@
import { integer, pgTable, primaryKey, varchar } from 'drizzle-orm/pg-core';
/**
* Per-prefix daily counters used to generate document codes.
*/
export const documentSequences = pgTable(
'document_sequences',
{
prefix: varchar('prefix', { length: 8 }).notNull(),
period: varchar('period', { length: 8 }).notNull(),
lastValue: integer('last_value').notNull(),
},
(t) => [primaryKey({ columns: [t.prefix, t.period] })],
);
export type DocumentSequenceRow = typeof documentSequences.$inferSelect;
export type NewDocumentSequenceRow = typeof documentSequences.$inferInsert;
+29
View File
@@ -0,0 +1,29 @@
import { pgTable, text, uniqueIndex, uuid, varchar } from 'drizzle-orm/pg-core';
import { primaryEntityColumns } from './primary-entity-columns';
import { users } from './schema';
/**
* Employees (primary aggregate).
* Kept in a separate module so Drizzle's table type stays resolvable.
*/
export const employees = pgTable(
'employees',
{
id: uuid('id').defaultRandom().notNull().primaryKey(),
code: varchar('code', { length: 16 }).notNull(),
name: varchar('name', { length: 64 }).notNull(),
phone: text('phone').notNull(),
position: text('position').notNull(),
userId: uuid('user_id').references(() => users.id, {
onDelete: 'set null',
}),
...primaryEntityColumns(users),
},
(t) => [
uniqueIndex('employees_code_unique').on(t.code),
uniqueIndex('employees_user_id_unique').on(t.userId),
],
);
export type EmployeeRow = typeof employees.$inferSelect;
export type NewEmployeeRow = typeof employees.$inferInsert;
+129
View File
@@ -0,0 +1,129 @@
import { inArray } from 'drizzle-orm';
import type { CodeRelation, DefaultRelation } from '../common/http/response';
import type { DrizzleDB } from './database.module';
import {
branches,
customers,
divisions,
employees,
packingSlips,
products,
salesInvoices,
salesOrders,
salesRequests,
} from './schema';
type CatalogTable =
| typeof employees
| typeof branches
| typeof divisions
| typeof customers
| typeof products;
type CodeTable =
| typeof salesOrders
| typeof salesRequests
| typeof packingSlips
| typeof salesInvoices;
async function loadDefaultMap(
db: DrizzleDB,
table: CatalogTable,
ids: readonly string[],
): Promise<Map<string, DefaultRelation>> {
const unique = [...new Set(ids.filter((id) => id.length > 0))];
if (unique.length === 0) {
return new Map();
}
const rows = await db
.select({ id: table.id, code: table.code, name: table.name })
.from(table)
.where(inArray(table.id, unique));
return new Map(
rows.map((row) => [row.id, { id: row.id, code: row.code, name: row.name }]),
);
}
async function loadCodeMap(
db: DrizzleDB,
table: CodeTable,
ids: readonly string[],
): Promise<Map<string, CodeRelation>> {
const unique = [...new Set(ids.filter((id) => id.length > 0))];
if (unique.length === 0) {
return new Map();
}
const rows = await db
.select({ id: table.id, code: table.code })
.from(table)
.where(inArray(table.id, unique));
return new Map(rows.map((row) => [row.id, { id: row.id, code: row.code }]));
}
export function loadEmployeeRelationMap(db: DrizzleDB, ids: readonly string[]) {
return loadDefaultMap(db, employees, ids);
}
export function loadBranchRelationMap(db: DrizzleDB, ids: readonly string[]) {
return loadDefaultMap(db, branches, ids);
}
export function loadDivisionRelationMap(db: DrizzleDB, ids: readonly string[]) {
return loadDefaultMap(db, divisions, ids);
}
export function loadCustomerRelationMap(db: DrizzleDB, ids: readonly string[]) {
return loadDefaultMap(db, customers, ids);
}
export function loadProductRelationMap(db: DrizzleDB, ids: readonly string[]) {
return loadDefaultMap(db, products, ids);
}
export function loadSalesRequestRelationMap(
db: DrizzleDB,
ids: readonly string[],
) {
return loadCodeMap(db, salesRequests, ids);
}
export function loadSalesOrderRelationMap(
db: DrizzleDB,
ids: readonly string[],
) {
return loadCodeMap(db, salesOrders, ids);
}
export function loadPackingSlipRelationMap(
db: DrizzleDB,
ids: readonly string[],
) {
return loadCodeMap(db, packingSlips, ids);
}
export function loadSalesInvoiceRelationMap(
db: DrizzleDB,
ids: readonly string[],
) {
return loadCodeMap(db, salesInvoices, ids);
}
export function catalogRelationFromMap(
map: Map<string, DefaultRelation>,
id: string | null | undefined,
): DefaultRelation | null {
if (!id) {
return null;
}
return map.get(id) ?? null;
}
export function codeRelationFromMap(
map: Map<string, CodeRelation>,
id: string | null | undefined,
): CodeRelation | null {
if (!id) {
return null;
}
return map.get(id) ?? null;
}
+47
View File
@@ -0,0 +1,47 @@
import { inArray } from 'drizzle-orm';
import type { UserRelation } from '../common/http/response';
import type { DrizzleDB } from './database.module';
import { users } from './schema';
export async function loadUserRelationMap(
db: DrizzleDB,
ids: readonly string[],
): Promise<Map<string, UserRelation>> {
const unique = [...new Set(ids.filter((id) => id.length > 0))];
if (unique.length === 0) {
return new Map();
}
const rows = await db
.select({ id: users.id, username: users.username })
.from(users)
.where(inArray(users.id, unique));
return new Map(
rows.map((row) => [row.id, { id: row.id, username: row.username }]),
);
}
export function userRelationFromMap(
map: Map<string, UserRelation>,
id: string,
): UserRelation {
return map.get(id) ?? { id, username: '' };
}
export async function attachAuditUsers<
T extends { createdBy: string; updatedBy: string },
>(
db: DrizzleDB,
items: T[],
): Promise<
Array<T & { createdByUser: UserRelation; updatedByUser: UserRelation }>
> {
const map = await loadUserRelationMap(
db,
items.flatMap((item) => [item.createdBy, item.updatedBy]),
);
return items.map((item) => ({
...item,
createdByUser: userRelationFromMap(map, item.createdBy),
updatedByUser: userRelationFromMap(map, item.updatedBy),
}));
}
+60
View File
@@ -0,0 +1,60 @@
import {
bigint,
doublePrecision,
index,
numeric,
pgTable,
text,
uniqueIndex,
uuid,
varchar,
} from 'drizzle-orm/pg-core';
import { customers } from './customers-table';
import { products } from './products-table';
import { primaryEntityColumns } from './primary-entity-columns';
import { salesOrders } from './sales-orders-table';
import { users } from './schema';
export const packingSlips = pgTable(
'packing_slips',
{
id: uuid('id').defaultRandom().notNull().primaryKey(),
code: varchar('code', { length: 32 }).notNull(),
salesOrderId: uuid('sales_order_id').references(() => salesOrders.id, {
onDelete: 'restrict',
}),
salesOrderNumber: varchar('sales_order_number', { length: 32 }),
date: bigint('date', { mode: 'number' }).notNull(),
customerId: uuid('customer_id')
.notNull()
.references(() => customers.id, { onDelete: 'restrict' }),
address: text('address').notNull(),
latitude: doublePrecision('latitude'),
longitude: doublePrecision('longitude'),
notes: text('notes'),
...primaryEntityColumns(users),
},
(t) => [uniqueIndex('packing_slips_code_unique').on(t.code)],
);
export const packingSlipProducts = pgTable(
'packing_slip_products',
{
id: uuid('id').defaultRandom().notNull().primaryKey(),
packingSlipId: uuid('packing_slip_id')
.notNull()
.references(() => packingSlips.id, { onDelete: 'cascade' }),
productId: uuid('product_id')
.notNull()
.references(() => products.id, { onDelete: 'restrict' }),
quantity: numeric('quantity', { precision: 18, scale: 4 }).notNull(),
price: numeric('price', { precision: 18, scale: 4 }).notNull(),
},
(t) => [
index('packing_slip_products_packing_slip_id_idx').on(t.packingSlipId),
],
);
export type PackingSlipRow = typeof packingSlips.$inferSelect;
export type NewPackingSlipRow = typeof packingSlips.$inferInsert;
export type PackingSlipProductRow = typeof packingSlipProducts.$inferSelect;
+111
View File
@@ -0,0 +1,111 @@
import { sql } from 'drizzle-orm';
import {
bigint,
index,
integer,
jsonb,
pgTable,
text,
uniqueIndex,
uuid,
} from 'drizzle-orm/pg-core';
import { branches } from './branches-table';
import { customers } from './customers-table';
import { employees } from './employees-table';
import { packingSlips } from './packing-slips-table';
import { primaryEntityColumns } from './primary-entity-columns';
import { salesInvoices } from './sales-invoices-table';
import { users } from './schema';
import type { StoredRouteGeometry } from './cycles-table';
export const plans = pgTable(
'plans',
{
id: uuid('id').defaultRandom().notNull().primaryKey(),
employeeId: uuid('employee_id')
.notNull()
.references(() => employees.id, { onDelete: 'restrict' }),
purpose: text('purpose').notNull(),
date: bigint('date', { mode: 'number' }).notNull(),
startBranchId: uuid('start_branch_id')
.notNull()
.references(() => branches.id, { onDelete: 'restrict' }),
endBranchId: uuid('end_branch_id')
.notNull()
.references(() => branches.id, { onDelete: 'restrict' }),
routeGeometry: jsonb('route_geometry')
.$type<StoredRouteGeometry>()
.notNull(),
...primaryEntityColumns(users),
},
(t) => [
uniqueIndex('plans_employee_purpose_date_live_unique')
.on(t.employeeId, t.purpose, t.date)
.where(sql`${t.status} <> 'archived'`),
index('plans_employee_id_idx').on(t.employeeId),
],
);
export const planDestinations = pgTable(
'plan_destinations',
{
id: uuid('id').defaultRandom().notNull().primaryKey(),
planId: uuid('plan_id')
.notNull()
.references(() => plans.id, { onDelete: 'cascade' }),
customerId: uuid('customer_id')
.notNull()
.references(() => customers.id, { onDelete: 'restrict' }),
sortOrder: integer('sort_order').notNull(),
},
(t) => [
uniqueIndex('plan_destinations_plan_customer_unique').on(
t.planId,
t.customerId,
),
index('plan_destinations_plan_id_idx').on(t.planId),
],
);
export const planInvoices = pgTable(
'plan_invoices',
{
id: uuid('id').defaultRandom().notNull().primaryKey(),
planId: uuid('plan_id')
.notNull()
.references(() => plans.id, { onDelete: 'cascade' }),
invoiceId: uuid('invoice_id')
.notNull()
.references(() => salesInvoices.id, { onDelete: 'restrict' }),
},
(t) => [
uniqueIndex('plan_invoices_plan_invoice_unique').on(t.planId, t.invoiceId),
index('plan_invoices_plan_id_idx').on(t.planId),
],
);
export const planPackingSlips = pgTable(
'plan_packing_slips',
{
id: uuid('id').defaultRandom().notNull().primaryKey(),
planId: uuid('plan_id')
.notNull()
.references(() => plans.id, { onDelete: 'cascade' }),
packingSlipId: uuid('packing_slip_id')
.notNull()
.references(() => packingSlips.id, { onDelete: 'restrict' }),
},
(t) => [
uniqueIndex('plan_packing_slips_plan_slip_unique').on(
t.planId,
t.packingSlipId,
),
index('plan_packing_slips_plan_id_idx').on(t.planId),
],
);
export type PlanRow = typeof plans.$inferSelect;
export type NewPlanRow = typeof plans.$inferInsert;
export type PlanDestinationRow = typeof planDestinations.$inferSelect;
export type PlanInvoiceRow = typeof planInvoices.$inferSelect;
export type PlanPackingSlipRow = typeof planPackingSlips.$inferSelect;
+30
View File
@@ -0,0 +1,30 @@
import {
numeric,
pgTable,
uniqueIndex,
uuid,
varchar,
} from 'drizzle-orm/pg-core';
import { primaryEntityColumns } from './primary-entity-columns';
import { users } from './schema';
/**
* Products (primary aggregate).
* Kept in a separate module so Drizzle's table type stays resolvable.
*/
export const products = pgTable(
'products',
{
id: uuid('id').defaultRandom().notNull().primaryKey(),
code: varchar('code', { length: 32 }).notNull(),
name: varchar('name', { length: 128 }).notNull(),
unit: varchar('unit', { length: 16 }),
price: numeric('price', { precision: 18, scale: 4 }),
brand: varchar('brand', { length: 64 }),
...primaryEntityColumns(users),
},
(t) => [uniqueIndex('products_code_unique').on(t.code)],
);
export type ProductRow = typeof products.$inferSelect;
export type NewProductRow = typeof products.$inferInsert;
+33
View File
@@ -0,0 +1,33 @@
import { sql } from 'drizzle-orm';
import {
pgTable,
text,
uuid,
boolean,
jsonb,
uniqueIndex,
} from 'drizzle-orm/pg-core';
import { primaryEntityColumns } from './primary-entity-columns';
import { users } from './schema';
export const reportBookmarks = pgTable(
'report_bookmarks',
{
id: uuid('id').defaultRandom().notNull().primaryKey(),
groupName: text('group_name').notNull(),
uniqueName: text('unique_name').notNull(),
label: text('label').notNull(),
type: text('type').notNull(),
applied: boolean('applied').notNull().default(false),
configuration: jsonb('configuration').notNull(),
...primaryEntityColumns(users),
},
(t) => [
uniqueIndex('report_bookmarks_owner_report_type_applied_unique')
.on(t.createdBy, t.groupName, t.uniqueName, t.type)
.where(sql`${t.applied} = true`),
],
);
export type ReportBookmarkRow = typeof reportBookmarks.$inferSelect;
export type NewReportBookmarkRow = typeof reportBookmarks.$inferInsert;
+75
View File
@@ -0,0 +1,75 @@
import {
bigint,
doublePrecision,
index,
numeric,
pgTable,
text,
uniqueIndex,
uuid,
varchar,
} from 'drizzle-orm/pg-core';
import { branches } from './branches-table';
import { customers } from './customers-table';
import { employees } from './employees-table';
import { packingSlips } from './packing-slips-table';
import { products } from './products-table';
import { primaryEntityColumns } from './primary-entity-columns';
import { salesOrders } from './sales-orders-table';
import { divisions, users } from './schema';
export const salesInvoices = pgTable(
'sales_invoices',
{
id: uuid('id').defaultRandom().notNull().primaryKey(),
code: varchar('code', { length: 32 }).notNull(),
date: bigint('date', { mode: 'number' }).notNull(),
salesPersonId: uuid('sales_person_id')
.notNull()
.references(() => employees.id, { onDelete: 'restrict' }),
branchId: uuid('branch_id')
.notNull()
.references(() => branches.id, { onDelete: 'restrict' }),
divisionId: uuid('division_id')
.notNull()
.references(() => divisions.id, { onDelete: 'restrict' }),
customerId: uuid('customer_id')
.notNull()
.references(() => customers.id, { onDelete: 'restrict' }),
salesOrderId: uuid('sales_order_id').references(() => salesOrders.id, {
onDelete: 'restrict',
}),
salesOrderCode: varchar('sales_order_code', { length: 32 }),
packingSlipId: uuid('packing_slip_id').references(() => packingSlips.id, {
onDelete: 'restrict',
}),
packingSlipCode: varchar('packing_slip_code', { length: 32 }),
balance: numeric('balance', { precision: 18, scale: 4 }).notNull(),
address: text('address').notNull(),
latitude: doublePrecision('latitude'),
longitude: doublePrecision('longitude'),
notes: text('notes'),
...primaryEntityColumns(users),
},
(t) => [uniqueIndex('sales_invoices_code_unique').on(t.code)],
);
export const salesInvoiceProducts = pgTable(
'sales_invoice_products',
{
id: uuid('id').defaultRandom().notNull().primaryKey(),
salesInvoiceId: uuid('sales_invoice_id')
.notNull()
.references(() => salesInvoices.id, { onDelete: 'cascade' }),
productId: uuid('product_id')
.notNull()
.references(() => products.id, { onDelete: 'restrict' }),
quantity: numeric('quantity', { precision: 18, scale: 4 }).notNull(),
price: numeric('price', { precision: 18, scale: 4 }).notNull(),
},
(t) => [index('sales_invoice_products_invoice_id_idx').on(t.salesInvoiceId)],
);
export type SalesInvoiceRow = typeof salesInvoices.$inferSelect;
export type NewSalesInvoiceRow = typeof salesInvoices.$inferInsert;
export type SalesInvoiceProductRow = typeof salesInvoiceProducts.$inferSelect;
+88
View File
@@ -0,0 +1,88 @@
import {
bigint,
doublePrecision,
index,
numeric,
pgTable,
text,
uniqueIndex,
uuid,
varchar,
} from 'drizzle-orm/pg-core';
import { branches } from './branches-table';
import { customers } from './customers-table';
import { employees } from './employees-table';
import { products } from './products-table';
import { salesRequests } from './sales-requests-table';
import { primaryEntityColumns } from './primary-entity-columns';
import { divisions, users } from './schema';
export const salesOrders = pgTable(
'sales_orders',
{
id: uuid('id').defaultRandom().notNull().primaryKey(),
code: varchar('code', { length: 32 }).notNull(),
salesRequestId: uuid('sales_request_id').references(
() => salesRequests.id,
{
onDelete: 'restrict',
},
),
date: bigint('date', { mode: 'number' }).notNull(),
salesPersonId: uuid('sales_person_id')
.notNull()
.references(() => employees.id, { onDelete: 'restrict' }),
branchId: uuid('branch_id')
.notNull()
.references(() => branches.id, { onDelete: 'restrict' }),
divisionId: uuid('division_id')
.notNull()
.references(() => divisions.id, { onDelete: 'restrict' }),
customerId: uuid('customer_id')
.notNull()
.references(() => customers.id, { onDelete: 'restrict' }),
address: text('address').notNull(),
latitude: doublePrecision('latitude'),
longitude: doublePrecision('longitude'),
notes: text('notes'),
...primaryEntityColumns(users),
},
(t) => [
uniqueIndex('sales_orders_code_unique').on(t.code),
index('sales_orders_customer_id_idx').on(t.customerId),
],
);
export const salesOrderProducts = pgTable(
'sales_order_products',
{
id: uuid('id').defaultRandom().notNull().primaryKey(),
salesOrderId: uuid('sales_order_id')
.notNull()
.references(() => salesOrders.id, { onDelete: 'cascade' }),
productId: uuid('product_id')
.notNull()
.references(() => products.id, { onDelete: 'restrict' }),
quantity: numeric('quantity', { precision: 18, scale: 4 }).notNull(),
price: numeric('price', { precision: 18, scale: 4 }).notNull(),
},
(t) => [index('sales_order_products_request_id_idx').on(t.salesOrderId)],
);
export const salesOrderImages = pgTable(
'sales_order_images',
{
id: uuid('id').defaultRandom().notNull().primaryKey(),
salesOrderId: uuid('sales_order_id')
.notNull()
.references(() => salesOrders.id, { onDelete: 'cascade' }),
url: varchar('url', { length: 2048 }).notNull(),
description: varchar('description', { length: 255 }),
},
(t) => [index('sales_order_images_request_id_idx').on(t.salesOrderId)],
);
export type SalesOrderRow = typeof salesOrders.$inferSelect;
export type NewSalesOrderRow = typeof salesOrders.$inferInsert;
export type SalesOrderProductRow = typeof salesOrderProducts.$inferSelect;
export type SalesOrderImageRow = typeof salesOrderImages.$inferSelect;
+58
View File
@@ -0,0 +1,58 @@
import {
bigint,
index,
numeric,
pgTable,
text,
uniqueIndex,
uuid,
varchar,
} from 'drizzle-orm/pg-core';
import { primaryEntityColumns } from './primary-entity-columns';
import { salesInvoices } from './sales-invoices-table';
import { users } from './schema';
export const salesPayments = pgTable(
'sales_payments',
{
id: uuid('id').defaultRandom().notNull().primaryKey(),
code: varchar('code', { length: 32 }).notNull(),
date: bigint('date', { mode: 'number' }).notNull(),
notes: text('notes'),
...primaryEntityColumns(users),
},
(t) => [uniqueIndex('sales_payments_code_unique').on(t.code)],
);
export const salesPaymentImages = pgTable(
'sales_payment_images',
{
id: uuid('id').defaultRandom().notNull().primaryKey(),
salesPaymentId: uuid('sales_payment_id')
.notNull()
.references(() => salesPayments.id, { onDelete: 'cascade' }),
url: varchar('url', { length: 2048 }).notNull(),
description: varchar('description', { length: 255 }),
},
(t) => [index('sales_payment_images_payment_id_idx').on(t.salesPaymentId)],
);
export const salesPaymentInvoices = pgTable(
'sales_payment_invoices',
{
id: uuid('id').defaultRandom().notNull().primaryKey(),
salesPaymentId: uuid('sales_payment_id')
.notNull()
.references(() => salesPayments.id, { onDelete: 'cascade' }),
salesInvoiceId: uuid('sales_invoice_id')
.notNull()
.references(() => salesInvoices.id, { onDelete: 'restrict' }),
amount: numeric('amount', { precision: 18, scale: 4 }).notNull(),
},
(t) => [index('sales_payment_invoices_payment_id_idx').on(t.salesPaymentId)],
);
export type SalesPaymentRow = typeof salesPayments.$inferSelect;
export type NewSalesPaymentRow = typeof salesPayments.$inferInsert;
export type SalesPaymentImageRow = typeof salesPaymentImages.$inferSelect;
export type SalesPaymentInvoiceRow = typeof salesPaymentInvoices.$inferSelect;
+81
View File
@@ -0,0 +1,81 @@
import {
bigint,
doublePrecision,
index,
numeric,
pgTable,
text,
uniqueIndex,
uuid,
varchar,
} from 'drizzle-orm/pg-core';
import { branches } from './branches-table';
import { customers } from './customers-table';
import { employees } from './employees-table';
import { products } from './products-table';
import { primaryEntityColumns } from './primary-entity-columns';
import { divisions, users } from './schema';
export const salesRequests = pgTable(
'sales_requests',
{
id: uuid('id').defaultRandom().notNull().primaryKey(),
code: varchar('code', { length: 32 }).notNull(),
date: bigint('date', { mode: 'number' }).notNull(),
salesPersonId: uuid('sales_person_id')
.notNull()
.references(() => employees.id, { onDelete: 'restrict' }),
branchId: uuid('branch_id')
.notNull()
.references(() => branches.id, { onDelete: 'restrict' }),
divisionId: uuid('division_id')
.notNull()
.references(() => divisions.id, { onDelete: 'restrict' }),
customerId: uuid('customer_id')
.notNull()
.references(() => customers.id, { onDelete: 'restrict' }),
address: text('address').notNull(),
latitude: doublePrecision('latitude'),
longitude: doublePrecision('longitude'),
notes: text('notes'),
...primaryEntityColumns(users),
},
(t) => [
uniqueIndex('sales_requests_code_unique').on(t.code),
index('sales_requests_customer_id_idx').on(t.customerId),
],
);
export const salesRequestProducts = pgTable(
'sales_request_products',
{
id: uuid('id').defaultRandom().notNull().primaryKey(),
salesRequestId: uuid('sales_request_id')
.notNull()
.references(() => salesRequests.id, { onDelete: 'cascade' }),
productId: uuid('product_id')
.notNull()
.references(() => products.id, { onDelete: 'restrict' }),
quantity: numeric('quantity', { precision: 18, scale: 4 }).notNull(),
price: numeric('price', { precision: 18, scale: 4 }).notNull(),
},
(t) => [index('sales_request_products_request_id_idx').on(t.salesRequestId)],
);
export const salesRequestImages = pgTable(
'sales_request_images',
{
id: uuid('id').defaultRandom().notNull().primaryKey(),
salesRequestId: uuid('sales_request_id')
.notNull()
.references(() => salesRequests.id, { onDelete: 'cascade' }),
url: varchar('url', { length: 2048 }).notNull(),
description: varchar('description', { length: 255 }),
},
(t) => [index('sales_request_images_request_id_idx').on(t.salesRequestId)],
);
export type SalesRequestRow = typeof salesRequests.$inferSelect;
export type NewSalesRequestRow = typeof salesRequests.$inferInsert;
export type SalesRequestProductRow = typeof salesRequestProducts.$inferSelect;
export type SalesRequestImageRow = typeof salesRequestImages.$inferSelect;
+127
View File
@@ -8,13 +8,17 @@ import {
uniqueIndex,
uuid,
varchar,
type AnyPgColumn,
} from 'drizzle-orm/pg-core';
import { Status } from '../common/value-objects/status/status';
import { primaryEntityColumns } from './primary-entity-columns';
/**
* Application users. Timestamps are UTC unix milliseconds.
* privilege_id is nullable until a role is assigned (deny-by-default).
* FK to privileges.id is enforced in the migration (circular table dependency).
* Status / created_by / updated_by are declared here (not via primaryEntityColumns)
* because this table cannot pass itself the same way other tables pass `users`.
*/
export const users = pgTable(
'users',
@@ -24,8 +28,15 @@ export const users = pgTable(
passwordHash: text('password_hash').notNull(),
privilegeId: uuid('privilege_id'),
isSuperadmin: boolean('is_superadmin').notNull().default(false),
status: text('status').notNull().default(Status.DEFAULT),
createdAt: bigint('created_at', { mode: 'number' }).notNull(),
updatedAt: bigint('updated_at', { mode: 'number' }).notNull(),
createdBy: uuid('created_by')
.notNull()
.references((): AnyPgColumn => users.id),
updatedBy: uuid('updated_by')
.notNull()
.references((): AnyPgColumn => users.id),
},
(t) => [
uniqueIndex('users_username_unique').on(t.username),
@@ -143,3 +154,119 @@ export type DivisionRow = typeof divisions.$inferSelect;
export type NewDivisionRow = typeof divisions.$inferInsert;
export { branches, type BranchRow, type NewBranchRow } from './branches-table';
export {
customerContacts,
customers,
type CustomerContactRow,
type CustomerRow,
type NewCustomerContactRow,
type NewCustomerRow,
} from './customers-table';
export {
employees,
type EmployeeRow,
type NewEmployeeRow,
} from './employees-table';
export {
products,
type ProductRow,
type NewProductRow,
} from './products-table';
export {
documentSequences,
type DocumentSequenceRow,
type NewDocumentSequenceRow,
} from './document-sequences-table';
export {
salesRequestImages,
salesRequestProducts,
salesRequests,
type NewSalesRequestRow,
type SalesRequestImageRow,
type SalesRequestProductRow,
type SalesRequestRow,
} from './sales-requests-table';
export {
salesOrderImages,
salesOrderProducts,
salesOrders,
type NewSalesOrderRow,
type SalesOrderImageRow,
type SalesOrderProductRow,
type SalesOrderRow,
} from './sales-orders-table';
export {
packingSlipProducts,
packingSlips,
type NewPackingSlipRow,
type PackingSlipProductRow,
type PackingSlipRow,
} from './packing-slips-table';
export {
salesInvoiceProducts,
salesInvoices,
type NewSalesInvoiceRow,
type SalesInvoiceProductRow,
type SalesInvoiceRow,
} from './sales-invoices-table';
export {
salesPaymentImages,
salesPaymentInvoices,
salesPayments,
type NewSalesPaymentRow,
type SalesPaymentImageRow,
type SalesPaymentInvoiceRow,
type SalesPaymentRow,
} from './sales-payments-table';
export {
companySettings,
type CompanySettingsRow,
type NewCompanySettingsRow,
} from './company-settings-table';
export {
cycleDestinations,
cycleWeekdays,
cycles,
type CycleDestinationRow,
type CycleRow,
type CycleWeekdayRow,
type NewCycleRow,
} from './cycles-table';
export {
planDestinations,
planInvoices,
planPackingSlips,
plans,
type NewPlanRow,
type PlanDestinationRow,
type PlanInvoiceRow,
type PlanPackingSlipRow,
type PlanRow,
} from './plans-table';
export {
attendances,
type AttendanceRow,
type NewAttendanceRow,
} from './attendances-table';
export { visits, type VisitRow, type NewVisitRow } from './visits-table';
export {
timelineFootprints,
type TimelineFootprintRow,
type NewTimelineFootprintRow,
} from './timeline-footprints-table';
export {
timelineActivities,
TIMELINE_ACTIVITY_TYPES,
type TimelineActivityType,
type TimelineActivityRow,
type NewTimelineActivityRow,
} from './timeline-activities-table';
export {
reportBookmarks,
type NewReportBookmarkRow,
type ReportBookmarkRow,
} from './report-bookmarks-table';
+56
View File
@@ -0,0 +1,56 @@
import {
bigint,
doublePrecision,
index,
pgTable,
text,
uuid,
} from 'drizzle-orm/pg-core';
import { customers } from './customers-table';
import { employees } from './employees-table';
import { visits } from './visits-table';
export const TIMELINE_ACTIVITY_TYPES = [
'branch_check_in',
'branch_check_out',
'customer_check_in',
'customer_check_out',
'sales_order_created',
'sales_request_created',
'sales_payment_created',
'customer_created',
] as const;
export type TimelineActivityType = (typeof TIMELINE_ACTIVITY_TYPES)[number];
export const timelineActivities = pgTable(
'timeline_activities',
{
id: uuid('id').defaultRandom().notNull().primaryKey(),
employeeId: uuid('employee_id')
.notNull()
.references(() => employees.id, { onDelete: 'restrict' }),
customerId: uuid('customer_id').references(() => customers.id, {
onDelete: 'set null',
}),
visitId: uuid('visit_id').references(() => visits.id, {
onDelete: 'set null',
}),
type: text('type').notNull(),
sourceType: text('source_type').notNull(),
sourceId: uuid('source_id').notNull(),
latitude: doublePrecision('latitude').notNull(),
longitude: doublePrecision('longitude').notNull(),
recordedAt: bigint('recorded_at', { mode: 'number' }).notNull(),
},
(t) => [
index('timeline_activities_employee_recorded_idx').on(
t.employeeId,
t.recordedAt,
),
index('timeline_activities_visit_id_idx').on(t.visitId),
],
);
export type TimelineActivityRow = typeof timelineActivities.$inferSelect;
export type NewTimelineActivityRow = typeof timelineActivities.$inferInsert;
+30
View File
@@ -0,0 +1,30 @@
import {
bigint,
doublePrecision,
index,
pgTable,
uuid,
} from 'drizzle-orm/pg-core';
import { employees } from './employees-table';
export const timelineFootprints = pgTable(
'timeline_footprints',
{
id: uuid('id').defaultRandom().notNull().primaryKey(),
employeeId: uuid('employee_id')
.notNull()
.references(() => employees.id, { onDelete: 'restrict' }),
latitude: doublePrecision('latitude').notNull(),
longitude: doublePrecision('longitude').notNull(),
recordedAt: bigint('recorded_at', { mode: 'number' }).notNull(),
},
(t) => [
index('timeline_footprints_employee_recorded_idx').on(
t.employeeId,
t.recordedAt,
),
],
);
export type TimelineFootprintRow = typeof timelineFootprints.$inferSelect;
export type NewTimelineFootprintRow = typeof timelineFootprints.$inferInsert;
+47
View File
@@ -0,0 +1,47 @@
import { sql } from 'drizzle-orm';
import { bigint, index, pgTable, uniqueIndex, uuid } from 'drizzle-orm/pg-core';
import { attendances } from './attendances-table';
import { checkInColumns, checkOutColumns } from './checkpoint-columns';
import { customers } from './customers-table';
import { employees } from './employees-table';
import { planDestinations, plans } from './plans-table';
import { primaryEntityColumns } from './primary-entity-columns';
import { users } from './schema';
export const visits = pgTable(
'visits',
{
id: uuid('id').defaultRandom().notNull().primaryKey(),
employeeId: uuid('employee_id')
.notNull()
.references(() => employees.id, { onDelete: 'restrict' }),
customerId: uuid('customer_id')
.notNull()
.references(() => customers.id, { onDelete: 'restrict' }),
attendanceId: uuid('attendance_id').references(() => attendances.id, {
onDelete: 'set null',
}),
planId: uuid('plan_id').references(() => plans.id, {
onDelete: 'set null',
}),
planDestinationId: uuid('plan_destination_id').references(
() => planDestinations.id,
{ onDelete: 'set null' },
),
date: bigint('date', { mode: 'number' }).notNull(),
...checkInColumns,
...checkOutColumns,
...primaryEntityColumns(users),
},
(t) => [
uniqueIndex('visits_employee_open_unique')
.on(t.employeeId)
.where(sql`${t.checkOutAt} IS NULL AND ${t.status} <> 'archived'`),
index('visits_employee_id_idx').on(t.employeeId),
index('visits_customer_id_idx').on(t.customerId),
index('visits_attendance_id_idx').on(t.attendanceId),
],
);
export type VisitRow = typeof visits.$inferSelect;
export type NewVisitRow = typeof visits.$inferInsert;
+1
View File
@@ -7,6 +7,7 @@ async function bootstrap() {
const env = loadEnv();
const app = await NestFactory.create(AppModule);
configureApp(app);
app.enableCors();
await app.listen(env.PORT);
}
void bootstrap();
+18 -4
View File
@@ -1,5 +1,6 @@
import { Test, TestingModule } from '@nestjs/testing';
import { PrivilegesService } from '../privileges/privileges.service';
import { EmployeesService } from '../configuration/employees/employees.service';
import { UsersService } from '../users/users.service';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
@@ -13,12 +14,16 @@ describe('AuthController', () => {
let privilegesService: jest.Mocked<
Pick<PrivilegesService, 'findPrivilegeSummary' | 'getPermissionsMap'>
>;
let employeesService: jest.Mocked<
Pick<EmployeesService, 'findRelationByUserId'>
>;
beforeEach(async () => {
authService = {
register: jest.fn().mockResolvedValue({
accessToken: 'a',
refreshToken: 'b'.repeat(64),
id: 'user-1',
username: 'alice',
status: 'draft',
}),
login: jest.fn().mockResolvedValue({
accessToken: 'a',
@@ -42,6 +47,9 @@ describe('AuthController', () => {
findPrivilegeSummary: jest.fn(),
getPermissionsMap: jest.fn(),
};
employeesService = {
findRelationByUserId: jest.fn().mockResolvedValue(null),
};
const moduleRef: TestingModule = await Test.createTestingModule({
controllers: [AuthController],
@@ -49,6 +57,7 @@ describe('AuthController', () => {
{ provide: AuthService, useValue: authService },
{ provide: UsersService, useValue: usersService },
{ provide: PrivilegesService, useValue: privilegesService },
{ provide: EmployeesService, useValue: employeesService },
],
}).compile();
@@ -90,6 +99,7 @@ describe('AuthController', () => {
username: 'alice',
isSuperadmin: false,
privilege: null,
employee: null,
permissions: {},
});
});
@@ -105,9 +115,10 @@ describe('AuthController', () => {
id: 'priv-1',
name: 'Admin',
code: 'ADMIN',
status: 'active',
});
privilegesService.getPermissionsMap.mockResolvedValue({
PRIVILEGES: {
'ADMIN.SETTINGS.USER.PRIVILEGES': {
view: true,
create: true,
update: true,
@@ -126,7 +137,9 @@ describe('AuthController', () => {
).resolves.toMatchObject({
privilege: { id: 'priv-1', code: 'ADMIN' },
permissions: {
PRIVILEGES: expect.objectContaining({ view: true }),
'ADMIN.SETTINGS.USER.PRIVILEGES': expect.objectContaining({
view: true,
}),
},
});
});
@@ -151,6 +164,7 @@ describe('AuthController', () => {
username: 'alice',
isSuperadmin: true,
privilege: null,
employee: null,
permissions: {},
});
});
+9 -2
View File
@@ -17,6 +17,7 @@ import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { Public } from '../../common/decorators/public.decorator';
import { BEARER_AUTH_NAME } from '../../common/swagger/setup-swagger';
import { PrivilegesService } from '../privileges/privileges.service';
import { EmployeesService } from '../configuration/employees/employees.service';
import { UsersService } from '../users/users.service';
import { AuthService } from './auth.service';
import {
@@ -24,6 +25,7 @@ import {
MeResponseDto,
RefreshTokenDto,
RegisterDto,
RegisterResponseDto,
TokenPairDto,
} from './dto/auth.dto';
@@ -34,17 +36,18 @@ export class AuthController {
private readonly authService: AuthService,
private readonly usersService: UsersService,
private readonly privilegesService: PrivilegesService,
private readonly employeesService: EmployeesService,
) {}
@Public()
@Throttle({ default: { limit: 5, ttl: 60_000 } })
@Post('register')
@ApiOperation({ summary: 'Register a new user' })
@ApiCreatedResponse({ type: TokenPairDto })
@ApiCreatedResponse({ type: RegisterResponseDto })
@ApiBadRequestResponse({ description: 'Validation failed' })
@ApiConflictResponse({ description: 'Username already registered' })
@ApiTooManyRequestsResponse({ description: 'Rate limit exceeded' })
register(@Body() dto: RegisterDto): Promise<TokenPairDto> {
register(@Body() dto: RegisterDto): Promise<RegisterResponseDto> {
return this.authService.register(dto.username, dto.password);
}
@@ -96,12 +99,15 @@ export class AuthController {
async me(@CurrentUser() user: AuthUser): Promise<MeResponseDto> {
const full = await this.usersService.findById(user.id);
const isSuperadmin = full?.isSuperadmin ?? user.isSuperadmin;
const employee = await this.employeesService.findRelationByUserId(user.id);
if (!full?.privilegeId) {
return {
id: user.id,
username: user.username,
isSuperadmin,
privilege: null,
employee,
permissions: {},
};
}
@@ -118,6 +124,7 @@ export class AuthController {
username: user.username,
isSuperadmin,
privilege,
employee,
permissions,
};
}
+6 -1
View File
@@ -7,6 +7,7 @@ import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { PrivilegesGuard } from '../../common/guards/privileges.guard';
import { PrivilegesModule } from '../privileges/privileges.module';
import { EmployeesModule } from '../configuration/employees/employees.module';
import { UsersModule } from '../users/users.module';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
@@ -17,6 +18,7 @@ import { JwtStrategy } from './strategies/jwt.strategy';
@Module({
imports: [
UsersModule,
EmployeesModule,
PrivilegesModule,
PassportModule.register({ defaultStrategy: 'jwt' }),
JwtModule.registerAsync({
@@ -31,7 +33,10 @@ import { JwtStrategy } from './strategies/jwt.strategy';
},
}),
}),
ThrottlerModule.forRoot([{ ttl: 60_000, limit: 100 }]),
ThrottlerModule.forRoot({
skipIf: () => process.env.NODE_ENV === 'test',
throttlers: [{ ttl: 60_000, limit: 100 }],
}),
],
controllers: [AuthController],
providers: [
+25 -8
View File
@@ -5,6 +5,7 @@ import { Test, TestingModule } from '@nestjs/testing';
import * as bcrypt from 'bcrypt';
import { createHash } from 'node:crypto';
import { DateTime } from '../../common/value-objects/date-time/date-time';
import { Status } from '../../common/value-objects/status/status';
import type { User } from '../users/user';
import { UsersService } from '../users/users.service';
import { AuthService } from './auth.service';
@@ -17,7 +18,10 @@ import { RevokedAccessTokensRepository } from './revoked-access-tokens.repositor
describe('AuthService', () => {
let service: AuthService;
let usersService: jest.Mocked<
Pick<UsersService, 'create' | 'findByUsername' | 'findById'>
Pick<
UsersService,
'create' | 'findByUsername' | 'findById' | 'assertCanAuthenticate'
>
>;
let jwtService: jest.Mocked<Pick<JwtService, 'signAsync'>>;
let config: { getOrThrow: jest.Mock };
@@ -46,14 +50,22 @@ describe('AuthService', () => {
passwordHash: await bcrypt.hash('password123', 4),
privilegeId: null,
isSuperadmin: false,
status: Status.create('active'),
createdAt: now,
updatedAt: now,
createdBy: 'user-1',
updatedBy: 'user-1',
privilege: null,
employee: null,
createdByUser: { id: 'user-1', username: 'alice' },
updatedByUser: { id: 'user-1', username: 'alice' },
};
usersService = {
create: jest.fn(),
findByUsername: jest.fn(),
findById: jest.fn(),
assertCanAuthenticate: jest.fn(),
};
jwtService = {
signAsync: jest.fn().mockResolvedValue('access.jwt.token'),
@@ -108,17 +120,22 @@ describe('AuthService', () => {
service = moduleRef.get(AuthService);
});
it('register creates user and returns token pair', async () => {
it('register creates a draft user and does not issue tokens', async () => {
usersService.findByUsername.mockResolvedValue(null);
usersService.create.mockResolvedValue(user);
usersService.create.mockResolvedValue({
...user,
status: Status.create('draft'),
});
const pair = await service.register('Alice', 'password123');
const result = await service.register('Alice', 'password123');
expect(usersService.create).toHaveBeenCalled();
expect(pair.accessToken).toBe('access.jwt.token');
expect(pair.refreshToken).toHaveLength(64);
expect(Object.keys(pair).sort()).toEqual(['accessToken', 'refreshToken']);
expect(refreshTokensRepository.create).toHaveBeenCalled();
expect(result).toEqual({
id: 'user-1',
username: 'alice',
status: 'draft',
});
expect(refreshTokensRepository.create).not.toHaveBeenCalled();
});
it('register throws ConflictException when username exists', async () => {
+11 -3
View File
@@ -33,7 +33,10 @@ export class AuthService {
private readonly revokedAccessTokensRepository: RevokedAccessTokensRepository,
) {}
async register(username: string, password: string): Promise<TokenPair> {
async register(
username: string,
password: string,
): Promise<{ id: string; username: string; status: string }> {
const existing = await this.usersService.findByUsername(username);
if (existing) {
throw new ConflictException('Username already registered');
@@ -41,8 +44,11 @@ export class AuthService {
const saltRounds = this.config.getOrThrow<number>('BCRYPT_SALT_ROUNDS');
const passwordHash = await bcrypt.hash(password, saltRounds);
const user = await this.usersService.create(username, passwordHash);
const { tokens } = await this.issueTokenPair(user);
return tokens;
return {
id: user.id,
username: user.username,
status: user.status.value,
};
}
async login(username: string, password: string): Promise<TokenPair> {
@@ -52,6 +58,7 @@ export class AuthService {
if (!user || !match) {
throw new UnauthorizedException('Invalid credentials');
}
this.usersService.assertCanAuthenticate(user);
const { tokens } = await this.issueTokenPair(user);
return tokens;
}
@@ -78,6 +85,7 @@ export class AuthService {
if (!user) {
throw new UnauthorizedException('Invalid refresh token');
}
this.usersService.assertCanAuthenticate(user);
await this.denylistAccessJti(claimed.accessJti);
const issued = await this.issueTokenPair(user);
+29 -1
View File
@@ -54,6 +54,17 @@ export class RefreshTokenDto {
refreshToken!: string;
}
export class RegisterResponseDto {
@ApiProperty({ format: 'uuid' })
id!: string;
@ApiProperty({ example: 'alice' })
username!: string;
@ApiProperty({ example: 'draft' })
status!: string;
}
export class TokenPairDto implements TokenPair {
@ApiProperty({
example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.example',
@@ -79,6 +90,20 @@ export class MePrivilegeDto {
code!: string;
}
export class MeEmployeeDto {
@ApiProperty({ format: 'uuid' })
id!: string;
@ApiProperty()
code!: string;
@ApiProperty()
name!: string;
@ApiProperty({ example: 'sales' })
position!: string;
}
export class MeResponseDto {
@ApiProperty({ example: '550e8400-e29b-41d4-a716-446655440000' })
id!: string;
@@ -92,10 +117,13 @@ export class MeResponseDto {
@ApiProperty({ type: MePrivilegeDto, nullable: true })
privilege!: MePrivilegeDto | null;
@ApiProperty({ type: MeEmployeeDto, nullable: true })
employee!: MeEmployeeDto | null;
@ApiProperty({
description: 'Permission matrix keyed by privilege key code',
example: {
PRIVILEGES: {
'ADMIN.SETTINGS.USER.PRIVILEGES': {
view: true,
create: false,
update: false,
@@ -2,6 +2,7 @@ import { UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Test, TestingModule } from '@nestjs/testing';
import { DateTime } from '../../../common/value-objects/date-time/date-time';
import { Status } from '../../../common/value-objects/status/status';
import type { User } from '../../users/user';
import { UsersService } from '../../users/users.service';
import { RevokedAccessTokensRepository } from '../revoked-access-tokens.repository';
@@ -9,7 +10,9 @@ import { JwtStrategy } from './jwt.strategy';
describe('JwtStrategy', () => {
let strategy: JwtStrategy;
let usersService: jest.Mocked<Pick<UsersService, 'findById'>>;
let usersService: jest.Mocked<
Pick<UsersService, 'findById' | 'assertCanAuthenticate'>
>;
let revoked: jest.Mocked<Pick<RevokedAccessTokensRepository, 'exists'>>;
const now = DateTime.fromUnixMs(1_700_000_000_000);
@@ -19,12 +22,22 @@ describe('JwtStrategy', () => {
passwordHash: 'hash',
privilegeId: null,
isSuperadmin: false,
status: Status.create('active'),
createdAt: now,
updatedAt: now,
createdBy: 'user-1',
updatedBy: 'user-1',
privilege: null,
employee: null,
createdByUser: { id: 'user-1', username: 'alice' },
updatedByUser: { id: 'user-1', username: 'alice' },
};
beforeEach(async () => {
usersService = { findById: jest.fn() };
usersService = {
findById: jest.fn(),
assertCanAuthenticate: jest.fn(),
};
revoked = { exists: jest.fn() };
const moduleRef: TestingModule = await Test.createTestingModule({
@@ -40,6 +40,7 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
if (!user) {
throw new UnauthorizedException('User not found');
}
this.usersService.assertCanAuthenticate(user);
return {
id: user.id,
@@ -21,6 +21,19 @@ export type Branch = {
readonly updatedAt: DateTime;
readonly createdBy: string;
readonly updatedBy: string;
readonly division: {
readonly id: string;
readonly code: string;
readonly name: string;
} | null;
readonly createdByUser: {
readonly id: string;
readonly username: string;
};
readonly updatedByUser: {
readonly id: string;
readonly username: string;
};
};
export type CreateBranchInput = {
@@ -69,6 +82,8 @@ export type ListBranchesFilters = {
readonly workingHoursStart?: string;
readonly workingHoursEnd?: string;
readonly search?: string;
readonly orderBy?: string;
readonly orderType?: string;
readonly limit: number;
readonly offset: number;
};
@@ -18,7 +18,7 @@ import { BEARER_AUTH_NAME } from '../../../common/swagger/setup-swagger';
import { BranchDto, ListBranchesQueryDto } from './dto/branch.dto';
import { BranchesService } from './branches.service';
export const BRANCH_PRIVILEGE_KEY = 'CONFIGURATION.BRANCH';
export const BRANCH_PRIVILEGE_KEY = 'ADMIN.SETTINGS.DATA.BRANCH';
@ApiTags('branches')
@ApiBearerAuth(BEARER_AUTH_NAME)
@@ -26,6 +26,7 @@ describe('BranchesRepository', () => {
const del = jest.fn();
const transaction = jest.fn();
const $dynamic = jest.fn();
const leftJoin = jest.fn();
const db = {
select,
@@ -56,6 +57,13 @@ describe('BranchesRepository', () => {
updatedBy: 'user-1',
};
const joinedRow = {
branch: row,
division: { id: 'div-1', code: 'JKT', name: 'Jakarta' },
createdByUser: { id: 'user-1', username: 'admin' },
updatedByUser: { id: 'user-1', username: 'admin' },
};
const createInput = {
code: 'JKT_01',
name: 'Jakarta Pusat',
@@ -68,16 +76,34 @@ describe('BranchesRepository', () => {
userId: 'user-1',
};
beforeEach(async () => {
jest.clearAllMocks();
where.mockImplementation(() => ({ limit, orderBy }));
orderBy.mockImplementation(() => ({ limit }));
limit.mockImplementation(() => ({ offset }));
offset.mockResolvedValue([row]);
from.mockImplementation(() => ({
const joinChain = () => {
const chain: {
leftJoin: jest.Mock;
where: typeof where;
$dynamic: typeof $dynamic;
} = {
leftJoin: jest.fn(),
where,
$dynamic,
};
chain.leftJoin.mockReturnValue(chain);
return chain;
};
beforeEach(async () => {
jest.clearAllMocks();
where.mockImplementation(() => ({ limit, orderBy, returning }));
orderBy.mockImplementation(() => ({ limit }));
limit.mockImplementation(() => ({
offset,
then: (
resolve: (value: (typeof joinedRow)[]) => unknown,
reject?: (reason: unknown) => unknown,
) => Promise.resolve([joinedRow]).then(resolve, reject),
}));
offset.mockResolvedValue([joinedRow]);
from.mockImplementation(() => joinChain());
leftJoin.mockImplementation(() => joinChain());
$dynamic.mockReturnValue({ where });
select.mockImplementation(() => ({ from }));
values.mockReturnValue({ returning });
@@ -86,7 +112,6 @@ describe('BranchesRepository', () => {
update.mockReturnValue({ set });
del.mockReturnValue({ where });
returning.mockResolvedValue([row]);
where.mockImplementation(() => ({ limit, orderBy, returning }));
const moduleRef: TestingModule = await Test.createTestingModule({
providers: [BranchesRepository, { provide: DRIZZLE, useValue: db }],
@@ -95,13 +120,15 @@ describe('BranchesRepository', () => {
});
it('findById maps a row to domain Branch', async () => {
limit.mockResolvedValueOnce([row]);
const branch = await repository.findById('br-1');
expect(branch).toMatchObject({
id: 'br-1',
code: 'JKT_01',
name: 'Jakarta Pusat',
createdBy: 'user-1',
division: { id: 'div-1', code: 'JKT', name: 'Jakarta' },
createdByUser: { id: 'user-1', username: 'admin' },
updatedByUser: { id: 'user-1', username: 'admin' },
});
expect(branch?.phone.value).toBe('+6281234567890');
expect(branch?.status.value).toBe('draft');
@@ -109,12 +136,11 @@ describe('BranchesRepository', () => {
});
it('findById returns null when missing', async () => {
limit.mockResolvedValueOnce([]);
limit.mockImplementationOnce(() => Promise.resolve([]));
await expect(repository.findById('missing')).resolves.toBeNull();
});
it('findByCode maps a row', async () => {
limit.mockResolvedValueOnce([row]);
const branch = await repository.findByCode('JKT_01');
expect(branch?.code).toBe('JKT_01');
});
@@ -127,17 +153,10 @@ describe('BranchesRepository', () => {
}),
}))
.mockImplementationOnce(() => ({
from: () => ({
$dynamic: () => ({
where: () => ({
orderBy: () => ({
limit: () => ({
offset: () => Promise.resolve([row]),
}),
}),
}),
}),
}),
from: () => {
const chain = joinChain();
return chain;
},
}));
const result = await repository.list({
@@ -158,12 +177,18 @@ describe('BranchesRepository', () => {
});
expect(result.total).toBe(1);
expect(result.data[0].code).toBe('JKT_01');
expect(result.data[0].division).toEqual({
id: 'div-1',
code: 'JKT',
name: 'Jakarta',
});
});
it('create inserts and maps unique violations', async () => {
returning.mockResolvedValueOnce([row]);
const created = await repository.create(createInput);
expect(created.code).toBe('JKT_01');
expect(created.createdByUser).toEqual({ id: 'user-1', username: 'admin' });
returning.mockRejectedValueOnce({ code: '23505' });
await expect(repository.create(createInput)).rejects.toBeInstanceOf(
@@ -209,17 +234,17 @@ describe('BranchesRepository', () => {
'user-1',
);
expect(updated.id).toBe('br-1');
expect(updated.updatedByUser).toEqual({ id: 'user-1', username: 'admin' });
});
it('update throws when missing', async () => {
limit.mockResolvedValueOnce([]);
limit.mockImplementationOnce(() => Promise.resolve([]));
await expect(
repository.update('missing', { userId: 'user-1' }),
).rejects.toBeInstanceOf(NotFoundException);
});
it('update maps a row when present', async () => {
limit.mockResolvedValueOnce([row]);
returning.mockResolvedValueOnce([row]);
const updated = await repository.update('br-1', {
name: 'Jakarta Selatan',
@@ -5,7 +5,9 @@ import {
Injectable,
NotFoundException,
} from '@nestjs/common';
import { and, asc, count, eq, ilike, inArray, or, SQL } from 'drizzle-orm';
import { and, count, eq, ilike, inArray, or, SQL } from 'drizzle-orm';
import { toOrderClauses } from '../../../common/http/response';
import { alias } from 'drizzle-orm/pg-core';
import { DateTime } from '../../../common/value-objects/date-time/date-time';
import { PhoneNumber } from '../../../common/value-objects/phone-number/phone-number';
import { Status } from '../../../common/value-objects/status/status';
@@ -15,6 +17,7 @@ import {
type BranchRow,
type NewBranchRow,
} from '../../../database/branches-table';
import { divisions, users } from '../../../database/schema';
import type {
Branch,
CreateBranchInput,
@@ -22,6 +25,28 @@ import type {
UpdateBranchInput,
} from './branch';
const BRANCH_ORDER_COLUMNS = {
id: branches.id,
code: branches.code,
name: branches.name,
phone: branches.phone,
address: branches.address,
nfcId: branches.nfcId,
status: branches.status,
createdAt: branches.createdAt,
updatedAt: branches.updatedAt,
};
const createdByUsers = alias(users, 'created_by_users');
const updatedByUsers = alias(users, 'updated_by_users');
type BranchJoinedRow = {
branch: BranchRow;
division: typeof divisions.$inferSelect | null;
createdByUser: typeof users.$inferSelect | null;
updatedByUser: typeof users.$inferSelect | null;
};
@Injectable()
export class BranchesRepository {
constructor(@Inject(DRIZZLE) private readonly db: DrizzleDB) {}
@@ -36,11 +61,15 @@ export class BranchesRepository {
.where(where);
const totalRow = totalRows[0];
let qb = this.db.select().from(branches).$dynamic();
let qb = this.selectWithRelations().$dynamic();
qb = this.extendListQuery(qb, filters);
const rows = await qb
.where(where)
.orderBy(asc(branches.code))
.orderBy(
...toOrderClauses(BRANCH_ORDER_COLUMNS, filters, [
{ column: 'code', type: 'ASC' },
]),
)
.limit(filters.limit)
.offset(filters.offset);
@@ -59,9 +88,7 @@ export class BranchesRepository {
}
async findById(id: string): Promise<Branch | null> {
const rows: BranchRow[] = await this.db
.select()
.from(branches)
const rows = await this.selectWithRelations()
.where(eq(branches.id, id))
.limit(1);
const row = rows[0];
@@ -69,9 +96,7 @@ export class BranchesRepository {
}
async findByCode(code: string): Promise<Branch | null> {
const rows = await this.db
.select()
.from(branches)
const rows = await this.selectWithRelations()
.where(eq(branches.code, code))
.limit(1);
const row = rows[0];
@@ -87,7 +112,7 @@ export class BranchesRepository {
.values(this.toInsertValues(input, status, now, input.userId))
.returning();
const row = inserted[0];
return this.toDomain(row);
return this.requireById(row.id);
} catch (error) {
this.rethrowConstraintViolation(error);
}
@@ -151,7 +176,7 @@ export class BranchesRepository {
if (!row) {
throw new NotFoundException('Branch not found');
}
return this.toDomain(row);
return this.requireById(row.id);
} catch (error) {
this.rethrowConstraintViolation(error);
}
@@ -176,7 +201,7 @@ export class BranchesRepository {
if (!row) {
throw new NotFoundException('Branch not found');
}
return this.toDomain(row);
return this.requireById(row.id);
}
async bulkUpdateStatus(
@@ -221,6 +246,28 @@ export class BranchesRepository {
return deleted.length;
}
private selectWithRelations() {
return this.db
.select({
branch: branches,
division: divisions,
createdByUser: createdByUsers,
updatedByUser: updatedByUsers,
})
.from(branches)
.leftJoin(divisions, eq(branches.divisionId, divisions.id))
.leftJoin(createdByUsers, eq(branches.createdBy, createdByUsers.id))
.leftJoin(updatedByUsers, eq(branches.updatedBy, updatedByUsers.id));
}
private async requireById(id: string): Promise<Branch> {
const loaded = await this.findById(id);
if (!loaded) {
throw new NotFoundException('Branch not found');
}
return loaded;
}
private buildListWhere(filters: ListBranchesFilters): SQL | undefined {
const parts: SQL[] = [];
if (filters.code) {
@@ -299,29 +346,52 @@ export class BranchesRepository {
};
}
private toDomain(row: BranchRow): Branch {
private toDomain(row: BranchJoinedRow): Branch {
const branch = row.branch;
return {
id: row.id,
code: row.code,
name: row.name,
phone: PhoneNumber.create(row.phone),
address: row.address,
latitude: row.latitude,
longitude: row.longitude,
workingDaysStart: row.workingDaysStart,
workingDaysEnd: row.workingDaysEnd,
workingHoursStart: row.workingHoursStart,
workingHoursEnd: row.workingHoursEnd,
nfcId: row.nfcId,
divisionId: row.divisionId,
status: Status.create(row.status),
createdAt: DateTime.fromUnixMs(row.createdAt),
updatedAt: DateTime.fromUnixMs(row.updatedAt),
createdBy: row.createdBy,
updatedBy: row.updatedBy,
id: branch.id,
code: branch.code,
name: branch.name,
phone: PhoneNumber.create(branch.phone),
address: branch.address,
latitude: branch.latitude,
longitude: branch.longitude,
workingDaysStart: branch.workingDaysStart,
workingDaysEnd: branch.workingDaysEnd,
workingHoursStart: branch.workingHoursStart,
workingHoursEnd: branch.workingHoursEnd,
nfcId: branch.nfcId,
divisionId: branch.divisionId,
status: Status.create(branch.status),
createdAt: DateTime.fromUnixMs(branch.createdAt),
updatedAt: DateTime.fromUnixMs(branch.updatedAt),
createdBy: branch.createdBy,
updatedBy: branch.updatedBy,
division: this.toDefaultRelation(row.division),
createdByUser: this.toUserRelation(row.createdByUser, branch.createdBy),
updatedByUser: this.toUserRelation(row.updatedByUser, branch.updatedBy),
};
}
private toDefaultRelation(
row: { id: string; code: string; name: string } | null,
): Branch['division'] {
if (!row?.id) {
return null;
}
return { id: row.id, code: row.code, name: row.name };
}
private toUserRelation(
row: { id: string; username: string } | null,
fallbackId: string,
): Branch['createdByUser'] {
if (row?.id) {
return { id: row.id, username: row.username };
}
return { id: fallbackId, username: '' };
}
private rethrowConstraintViolation(error: unknown): never {
const err = error as { code?: string; constraint?: string };
if (err.code === '23505') {
@@ -44,6 +44,9 @@ describe('BranchesService', () => {
updatedAt: now,
createdBy: 'user-1',
updatedBy: 'user-1',
division: { id: 'div-1', code: 'JKT', name: 'Jakarta' },
createdByUser: { id: 'user-1', username: 'admin' },
updatedByUser: { id: 'user-1', username: 'admin' },
};
const createInput = {
@@ -92,8 +95,14 @@ describe('BranchesService', () => {
phone: '+6281234567890',
status: 'draft',
createdAt: now.value,
division: { id: 'div-1', code: 'JKT', name: 'Jakarta' },
createdBy: { id: 'user-1', username: 'admin' },
updatedBy: { id: 'user-1', username: 'admin' },
});
expect(result.data[0]).not.toHaveProperty('divisionId');
expect(service.visibleFields).toContain('phone');
expect(service.visibleFields).toContain('division');
expect(service.visibleFields).not.toContain('divisionId');
});
it('findById throws when missing', async () => {
@@ -108,6 +117,12 @@ describe('BranchesService', () => {
const result = await service.findById('br-1');
expect(result.id).toBe('br-1');
expect(result.phone).toBe('+6281234567890');
expect(result.division).toEqual({
id: 'div-1',
code: 'JKT',
name: 'Jakarta',
});
expect(result.createdBy).toEqual({ id: 'user-1', username: 'admin' });
});
it('create defaults status to draft and stores E.164 phone', async () => {
@@ -4,7 +4,12 @@ import {
NotFoundException,
} from '@nestjs/common';
import type { PaginationResponse } from '../../../common/http/response';
import { toListPage } from '../../../common/http/response';
import {
pickRelation,
pickUserRelation,
DEFAULT_RELATION_FIELDS,
toListPage,
} from '../../../common/http/response';
import { InvalidPhoneNumberError } from '../../../common/value-objects/phone-number/invalid-phone-number.error';
import { PhoneNumber } from '../../../common/value-objects/phone-number/phone-number';
import { Status } from '../../../common/value-objects/status/status';
@@ -36,6 +41,8 @@ export type ListBranchesQuery = {
readonly workingHoursStart?: string;
readonly workingHoursEnd?: string;
readonly search?: string;
readonly orderBy?: string;
readonly orderType?: string;
readonly page?: number;
readonly limit?: number;
readonly offset?: number;
@@ -54,7 +61,7 @@ const VISIBLE_FIELDS = [
'workingHoursStart',
'workingHoursEnd',
'nfcId',
'divisionId',
'division',
'status',
'createdAt',
'updatedAt',
@@ -94,6 +101,8 @@ export class BranchesService {
workingHoursStart: query.workingHoursStart,
workingHoursEnd: query.workingHoursEnd,
search: query.search,
orderBy: query.orderBy,
orderType: query.orderType,
limit: page.limit,
offset: page.offset,
});
@@ -113,6 +122,16 @@ export class BranchesService {
return this.toListItem(branch);
}
async findByCode(
code: string,
): Promise<ReturnType<BranchesService['toListItem']>> {
const branch = await this.branchesRepository.findByCode(code);
if (!branch) {
throw new NotFoundException('Branch not found');
}
return this.toListItem(branch);
}
async create(input: {
code: string;
name: string;
@@ -331,12 +350,12 @@ export class BranchesService {
workingHoursStart: branch.workingHoursStart,
workingHoursEnd: branch.workingHoursEnd,
nfcId: branch.nfcId,
divisionId: branch.divisionId,
division: pickRelation(branch.division, DEFAULT_RELATION_FIELDS),
status: branch.status.value,
createdAt: branch.createdAt.value,
updatedAt: branch.updatedAt.value,
createdBy: branch.createdBy,
updatedBy: branch.updatedBy,
createdBy: pickUserRelation(branch.createdByUser),
updatedBy: pickUserRelation(branch.updatedByUser),
};
}
@@ -14,7 +14,11 @@ import {
MaxLength,
Min,
} from 'class-validator';
import { PaginationQueryDto } from '../../../../common/http/response';
import {
DefaultRelationDto,
PaginationQueryDto,
UserRelationDto,
} from '../../../../common/http/response';
import { CORE_STATUSES } from '../../../../common/value-objects/status/status';
import {
BRANCH_ADDRESS_MAX_LENGTH,
@@ -320,8 +324,8 @@ export class BranchDto {
@ApiPropertyOptional({ nullable: true })
nfcId!: string | null;
@ApiPropertyOptional({ format: 'uuid', nullable: true })
divisionId!: string | null;
@ApiPropertyOptional({ type: DefaultRelationDto, nullable: true })
division!: DefaultRelationDto | null;
@ApiProperty({ enum: CORE_STATUSES })
status!: string;
@@ -332,9 +336,9 @@ export class BranchDto {
@ApiProperty({ description: 'Unix ms' })
updatedAt!: number;
@ApiProperty({ format: 'uuid' })
createdBy!: string;
@ApiProperty({ type: UserRelationDto })
createdBy!: UserRelationDto;
@ApiProperty({ format: 'uuid' })
updatedBy!: string;
@ApiProperty({ type: UserRelationDto })
updatedBy!: UserRelationDto;
}
@@ -1,9 +1,24 @@
import { Module } from '@nestjs/common';
import { BranchesModule } from './branches/branches.module';
import { CustomersModule } from './customers/customers.module';
import { DivisionsModule } from './divisions/divisions.module';
import { EmployeesModule } from './employees/employees.module';
import { ProductsModule } from './products/products.module';
@Module({
imports: [DivisionsModule, BranchesModule],
exports: [DivisionsModule, BranchesModule],
imports: [
DivisionsModule,
BranchesModule,
CustomersModule,
EmployeesModule,
ProductsModule,
],
exports: [
DivisionsModule,
BranchesModule,
CustomersModule,
EmployeesModule,
ProductsModule,
],
})
export class ConfigurationModule {}
@@ -0,0 +1,172 @@
import {
CONTACT_NAME_MAX_LENGTH,
CUSTOMER_CODE_MAX_LENGTH,
CUSTOMER_NAME_MAX_LENGTH,
isAllowedCsvUpload,
isValidContactJobTitle,
isValidContactName,
isValidContactNotes,
isValidCustomerAddress,
isValidCustomerCode,
isValidCustomerName,
isValidLatitude,
isValidLongitude,
isValidNfcId,
parseCsvRecord,
} from './customer-fields';
describe('customer fields', () => {
describe('isValidCustomerName', () => {
it.each(['Acme', 'South Jakarta', 'A', 'North West Region'])(
'accepts %s',
(name) => {
expect(isValidCustomerName(name)).toBe(true);
},
);
it.each([
'',
'Acme1',
'South-Jakarta',
'CUST_01',
' Acme',
'Acme ',
'South Jakarta',
])('rejects %s', (name) => {
expect(isValidCustomerName(name)).toBe(false);
});
it('rejects names longer than 64 characters', () => {
expect(
isValidCustomerName('A'.repeat(CUSTOMER_NAME_MAX_LENGTH + 1)),
).toBe(false);
expect(isValidCustomerName('A'.repeat(CUSTOMER_NAME_MAX_LENGTH))).toBe(
true,
);
});
});
describe('isValidCustomerCode', () => {
it.each(['CUST', 'CUST_01', 'A', 'ops2', 'A_b_1'])('accepts %s', (code) => {
expect(isValidCustomerCode(code)).toBe(true);
});
it.each(['', 'CUST 01', 'CUST-01', 'CUST.01', ' CUST', 'CUST '])(
'rejects %s',
(code) => {
expect(isValidCustomerCode(code)).toBe(false);
},
);
it('rejects codes longer than 16 characters', () => {
expect(
isValidCustomerCode('A'.repeat(CUSTOMER_CODE_MAX_LENGTH + 1)),
).toBe(false);
expect(isValidCustomerCode('A'.repeat(CUSTOMER_CODE_MAX_LENGTH))).toBe(
true,
);
});
});
describe('isValidCustomerAddress', () => {
it('accepts a non-empty address', () => {
expect(isValidCustomerAddress('Jl Sudirman No 1')).toBe(true);
});
it('rejects empty or oversized addresses', () => {
expect(isValidCustomerAddress('')).toBe(false);
expect(isValidCustomerAddress('A'.repeat(256))).toBe(false);
});
});
describe('coordinates', () => {
it('accepts latitude and longitude in range', () => {
expect(isValidLatitude(-90)).toBe(true);
expect(isValidLatitude(90)).toBe(true);
expect(isValidLongitude(-180)).toBe(true);
expect(isValidLongitude(180)).toBe(true);
});
it('rejects out of range coordinates', () => {
expect(isValidLatitude(-90.1)).toBe(false);
expect(isValidLatitude(90.1)).toBe(false);
expect(isValidLongitude(-180.1)).toBe(false);
expect(isValidLongitude(180.1)).toBe(false);
expect(isValidLatitude(Number.NaN)).toBe(false);
});
});
describe('isValidNfcId', () => {
it('accepts a non-empty NFC id', () => {
expect(isValidNfcId('NFC-001')).toBe(true);
});
it('rejects empty NFC id', () => {
expect(isValidNfcId('')).toBe(false);
});
});
describe('isValidContactName', () => {
it.each(["O'Brien", 'Jean-Luc', 'A', 'Li Wei'])('accepts %s', (name) => {
expect(isValidContactName(name)).toBe(true);
});
it('rejects empty or oversized names', () => {
expect(isValidContactName('')).toBe(false);
expect(isValidContactName(' ')).toBe(false);
expect(isValidContactName('A'.repeat(CONTACT_NAME_MAX_LENGTH + 1))).toBe(
false,
);
});
});
describe('isValidContactJobTitle and notes', () => {
it('accepts optional job title and notes within limits', () => {
expect(isValidContactJobTitle('Purchasing Manager')).toBe(true);
expect(isValidContactNotes('Call after 9am')).toBe(true);
});
it('rejects oversized job title or notes', () => {
expect(isValidContactJobTitle('A'.repeat(65))).toBe(false);
expect(isValidContactNotes('A'.repeat(256))).toBe(false);
});
});
describe('parseCsvRecord', () => {
it('keeps commas inside quoted fields', () => {
expect(
parseCsvRecord('CUST_01,Acme Corp,"Jl Sudirman No 1, Blok A"'),
).toEqual(['CUST_01', 'Acme Corp', 'Jl Sudirman No 1, Blok A']);
});
it('unescapes doubled quotes', () => {
expect(parseCsvRecord('"Say ""hello""",x')).toEqual(['Say "hello"', 'x']);
});
});
describe('isAllowedCsvUpload', () => {
it('accepts csv mime or .csv names', () => {
expect(
isAllowedCsvUpload({
mimetype: 'text/csv',
originalname: 'x.txt',
}),
).toBe(true);
expect(
isAllowedCsvUpload({
mimetype: 'application/octet-stream',
originalname: 'customers.csv',
}),
).toBe(true);
});
it('rejects non-csv files', () => {
expect(
isAllowedCsvUpload({
mimetype: 'application/pdf',
originalname: 'x.pdf',
}),
).toBe(false);
});
});
});
@@ -0,0 +1,112 @@
export const CUSTOMER_NAME_MAX_LENGTH = 64;
export const CUSTOMER_CODE_MAX_LENGTH = 16;
export const CUSTOMER_ADDRESS_MAX_LENGTH = 255;
export const CUSTOMER_NFC_ID_MAX_LENGTH = 64;
export const CONTACT_NAME_MAX_LENGTH = 64;
export const CONTACT_JOB_TITLE_MAX_LENGTH = 64;
export const CONTACT_NOTES_MAX_LENGTH = 255;
/** Letters with single spaces between words. */
export const CUSTOMER_NAME_PATTERN = /^[A-Za-z]+(?: [A-Za-z]+)*$/;
/** Alphanumeric and underscore; no spaces. */
export const CUSTOMER_CODE_PATTERN = /^[A-Za-z0-9_]+$/;
export function isValidCustomerName(raw: string): boolean {
return (
typeof raw === 'string' &&
raw.length > 0 &&
raw.length <= CUSTOMER_NAME_MAX_LENGTH &&
CUSTOMER_NAME_PATTERN.test(raw)
);
}
export function isValidCustomerCode(raw: string): boolean {
return (
typeof raw === 'string' &&
raw.length > 0 &&
raw.length <= CUSTOMER_CODE_MAX_LENGTH &&
CUSTOMER_CODE_PATTERN.test(raw)
);
}
export function isValidCustomerAddress(raw: string): boolean {
return (
typeof raw === 'string' &&
raw.length > 0 &&
raw.length <= CUSTOMER_ADDRESS_MAX_LENGTH
);
}
export function isValidLatitude(raw: number): boolean {
return Number.isFinite(raw) && raw >= -90 && raw <= 90;
}
export function isValidLongitude(raw: number): boolean {
return Number.isFinite(raw) && raw >= -180 && raw <= 180;
}
export function isValidNfcId(raw: string): boolean {
return (
typeof raw === 'string' &&
raw.length > 0 &&
raw.length <= CUSTOMER_NFC_ID_MAX_LENGTH
);
}
export function isValidContactName(raw: string): boolean {
return (
typeof raw === 'string' &&
raw.trim().length > 0 &&
raw.trim().length <= CONTACT_NAME_MAX_LENGTH
);
}
export function isValidContactJobTitle(raw: string): boolean {
return typeof raw === 'string' && raw.length <= CONTACT_JOB_TITLE_MAX_LENGTH;
}
export function isValidContactNotes(raw: string): boolean {
return typeof raw === 'string' && raw.length <= CONTACT_NOTES_MAX_LENGTH;
}
/** RFC 4180-style record split that preserves commas inside quotes. */
export function parseCsvRecord(line: string): string[] {
const cells: string[] = [];
let current = '';
let inQuotes = false;
for (let i = 0; i < line.length; i++) {
const ch = line[i];
if (inQuotes) {
if (ch === '"') {
if (line[i + 1] === '"') {
current += '"';
i += 1;
} else {
inQuotes = false;
}
} else {
current += ch;
}
} else if (ch === '"') {
inQuotes = true;
} else if (ch === ',') {
cells.push(current.trim());
current = '';
} else {
current += ch;
}
}
cells.push(current.trim());
return cells;
}
export function isAllowedCsvUpload(file: {
mimetype: string;
originalname: string;
}): boolean {
return (
file.mimetype.includes('csv') ||
file.originalname.toLowerCase().endsWith('.csv')
);
}
@@ -0,0 +1,89 @@
import type { UserRelation } from '../../../common/http/response';
import { DateTime } from '../../../common/value-objects/date-time/date-time';
import { PhoneNumber } from '../../../common/value-objects/phone-number/phone-number';
import { Status } from '../../../common/value-objects/status/status';
export type CustomerContact = {
readonly id: string;
readonly customerId: string;
readonly name: string;
readonly jobTitle: string | null;
readonly phone: PhoneNumber | null;
readonly mobilePhone: PhoneNumber | null;
readonly notes: string | null;
};
export type Customer = {
readonly id: string;
readonly code: string;
readonly name: string;
readonly phone: PhoneNumber;
readonly address: string;
readonly latitude: number | null;
readonly longitude: number | null;
readonly nfcId: string | null;
readonly contacts: readonly CustomerContact[];
readonly status: Status;
readonly createdAt: DateTime;
readonly updatedAt: DateTime;
readonly createdBy: string;
readonly updatedBy: string;
readonly createdByUser: UserRelation;
readonly updatedByUser: UserRelation;
};
export type CustomerContactInput = {
readonly name: string;
readonly jobTitle?: string | null;
readonly phone?: PhoneNumber | null;
readonly mobilePhone?: PhoneNumber | null;
readonly notes?: string | null;
};
export type CreateCustomerInput = {
readonly code: string;
readonly name: string;
readonly phone: PhoneNumber;
readonly address: string;
readonly latitude?: number | null;
readonly longitude?: number | null;
readonly nfcId?: string | null;
readonly contacts?: readonly CustomerContactInput[];
readonly status?: Status;
readonly userId: string;
};
export type UpdateCustomerInput = {
readonly code?: string;
readonly name?: string;
readonly phone?: PhoneNumber;
readonly address?: string;
readonly latitude?: number | null;
readonly longitude?: number | null;
readonly nfcId?: string | null;
readonly contacts?: readonly CustomerContactInput[];
readonly userId: string;
};
export type UpdateCustomerContactInput = {
readonly name?: string;
readonly jobTitle?: string | null;
readonly phone?: PhoneNumber | null;
readonly mobilePhone?: PhoneNumber | null;
readonly notes?: string | null;
readonly userId: string;
};
export type ListCustomersFilters = {
readonly code?: string;
readonly name?: string;
readonly phone?: string;
readonly address?: string;
readonly nfcId?: string;
readonly status?: string;
readonly search?: string;
readonly orderBy?: string;
readonly orderType?: string;
readonly limit: number;
readonly offset: number;
};
@@ -0,0 +1,34 @@
import { Test, TestingModule } from '@nestjs/testing';
import { CustomersReadController } from './customers-read.controller';
import { CustomersService } from './customers.service';
describe('CustomersReadController', () => {
let controller: CustomersReadController;
const service = {
list: jest.fn(),
findById: jest.fn(),
};
beforeEach(async () => {
jest.clearAllMocks();
const moduleRef: TestingModule = await Test.createTestingModule({
controllers: [CustomersReadController],
providers: [{ provide: CustomersService, useValue: service }],
}).compile();
controller = moduleRef.get(CustomersReadController);
});
it('list delegates to the service', async () => {
service.list.mockResolvedValue({ data: [], total: 0 });
await expect(controller.list({ page: 1 })).resolves.toEqual({
data: [],
total: 0,
});
expect(service.list).toHaveBeenCalledWith({ page: 1 });
});
it('findOne delegates to the service', async () => {
service.findById.mockResolvedValue({ id: 'cu-1' });
await expect(controller.findOne('cu-1')).resolves.toEqual({ id: 'cu-1' });
});
});
@@ -0,0 +1,67 @@
import { Controller, Get, Param, ParseUUIDPipe, Query } from '@nestjs/common';
import {
ApiBearerAuth,
ApiForbiddenResponse,
ApiNotFoundResponse,
ApiOkResponse,
ApiOperation,
ApiTags,
ApiUnauthorizedResponse,
} from '@nestjs/swagger';
import { RequirePrivilege } from '../../../common/decorators/require-privilege.decorator';
import {
Pagination,
type PaginationResponse,
PaginationMetaDto,
} from '../../../common/http/response';
import { BEARER_AUTH_NAME } from '../../../common/swagger/setup-swagger';
import { CustomerDto, ListCustomersQueryDto } from './dto/customer.dto';
import { CustomersService } from './customers.service';
export const CUSTOMER_PRIVILEGE_KEYS = [
'ADMIN.SETTINGS.DATA.CUSTOMER',
'MOBILE.SALES.CUSTOMER',
] as const;
@ApiTags('customers')
@ApiBearerAuth(BEARER_AUTH_NAME)
@Controller('customers')
export class CustomersReadController {
constructor(private readonly customersService: CustomersService) {}
@Get()
@Pagination()
@RequirePrivilege(CUSTOMER_PRIVILEGE_KEYS, 'view')
@ApiOperation({ summary: 'List customers' })
@ApiOkResponse({
schema: {
properties: {
data: {
type: 'array',
items: { $ref: '#/components/schemas/CustomerDto' },
},
meta: { $ref: '#/components/schemas/PaginationMetaDto' },
},
},
})
@ApiUnauthorizedResponse()
@ApiForbiddenResponse()
list(
@Query() query: ListCustomersQueryDto,
): Promise<PaginationResponse<CustomerDto>> {
return this.customersService.list(query);
}
@Get(':id')
@RequirePrivilege(CUSTOMER_PRIVILEGE_KEYS, 'view')
@ApiOperation({ summary: 'Get customer detail' })
@ApiOkResponse({ type: CustomerDto })
@ApiNotFoundResponse()
@ApiUnauthorizedResponse()
@ApiForbiddenResponse()
findOne(@Param('id', ParseUUIDPipe) id: string): Promise<CustomerDto> {
return this.customersService.findById(id);
}
}
void PaginationMetaDto;
@@ -0,0 +1,105 @@
import { Test, TestingModule } from '@nestjs/testing';
import { CustomersWriteController } from './customers-write.controller';
import { CustomersService } from './customers.service';
const createDto = {
code: 'CUST_01',
name: 'Acme Corp',
phone: '+6281234567890',
address: 'Jl Sudirman No 1',
};
describe('CustomersWriteController', () => {
let controller: CustomersWriteController;
const service = {
create: jest.fn(),
update: jest.fn(),
updateStatus: jest.fn(),
delete: jest.fn(),
bulkDelete: jest.fn(),
bulkUpdateStatus: jest.fn(),
importCsv: jest.fn(),
addContact: jest.fn(),
updateContact: jest.fn(),
deleteContact: jest.fn(),
};
beforeEach(async () => {
jest.clearAllMocks();
const moduleRef: TestingModule = await Test.createTestingModule({
controllers: [CustomersWriteController],
providers: [{ provide: CustomersService, useValue: service }],
}).compile();
controller = moduleRef.get(CustomersWriteController);
});
it('create passes dto fields and user id', async () => {
service.create.mockResolvedValue({ id: 'cu-1' });
await controller.create(createDto, 'user-1');
expect(service.create).toHaveBeenCalledWith({
...createDto,
userId: 'user-1',
});
});
it('update, updateStatus, and delete delegate', async () => {
service.update.mockResolvedValue({ id: 'cu-1' });
service.updateStatus.mockResolvedValue({ id: 'cu-1' });
service.delete.mockResolvedValue(undefined);
await controller.update('cu-1', { name: 'Acme Corp' }, 'user-1');
await controller.updateStatus('cu-1', { status: 'active' }, 'user-1');
await controller.delete('cu-1');
expect(service.updateStatus).toHaveBeenCalledWith(
'cu-1',
'active',
'user-1',
);
expect(service.delete).toHaveBeenCalledWith('cu-1');
});
it('nested contact routes delegate', async () => {
service.addContact.mockResolvedValue({ id: 'cu-1' });
service.updateContact.mockResolvedValue({ id: 'cu-1' });
service.deleteContact.mockResolvedValue(undefined);
await controller.addContact('cu-1', { name: 'Ada Lovelace' }, 'user-1');
await controller.updateContact(
'cu-1',
'ct-1',
{ jobTitle: 'Buyer' },
'user-1',
);
await controller.deleteContact('cu-1', 'ct-1');
expect(service.addContact).toHaveBeenCalledWith(
'cu-1',
{ name: 'Ada Lovelace' },
'user-1',
);
expect(service.updateContact).toHaveBeenCalledWith('cu-1', 'ct-1', {
jobTitle: 'Buyer',
userId: 'user-1',
});
expect(service.deleteContact).toHaveBeenCalledWith('cu-1', 'ct-1');
});
it('bulk and import delegate', async () => {
service.bulkDelete.mockResolvedValue({ deleted: 1 });
service.bulkUpdateStatus.mockResolvedValue({ updated: 1 });
service.importCsv.mockResolvedValue({ imported: 1 });
await controller.bulkDelete({ ids: ['cu-1'] });
await controller.bulkStatus(
{ ids: ['cu-1'], status: 'archived' },
'user-1',
);
await controller.importCsv(
{ buffer: Buffer.from('code,name\nCUST_01,Acme') },
'user-1',
);
expect(service.importCsv).toHaveBeenCalled();
});
it('importCsv uses empty string when file is missing', async () => {
service.importCsv.mockResolvedValue({ imported: 0 });
await controller.importCsv(undefined, 'user-1');
expect(service.importCsv).toHaveBeenCalledWith('', 'user-1');
});
});
@@ -0,0 +1,227 @@
import {
BadRequestException,
Body,
Controller,
Delete,
HttpCode,
Param,
ParseUUIDPipe,
Patch,
Post,
UploadedFile,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import {
ApiBearerAuth,
ApiBody,
ApiConsumes,
ApiCreatedResponse,
ApiForbiddenResponse,
ApiNoContentResponse,
ApiNotFoundResponse,
ApiOkResponse,
ApiOperation,
ApiTags,
ApiUnauthorizedResponse,
} from '@nestjs/swagger';
import { CurrentUser } from '../../../common/decorators/current-user.decorator';
import { RequirePrivilege } from '../../../common/decorators/require-privilege.decorator';
import { BEARER_AUTH_NAME } from '../../../common/swagger/setup-swagger';
import { isAllowedCsvUpload } from './customer-fields';
import { CUSTOMER_PRIVILEGE_KEYS } from './customers-read.controller';
import { CustomersService } from './customers.service';
import {
BulkIdsDto,
BulkStatusDto,
CreateCustomerContactDto,
CreateCustomerDto,
CustomerDto,
UpdateCustomerContactDto,
UpdateCustomerDto,
UpdateCustomerStatusDto,
} from './dto/customer.dto';
@ApiTags('customers')
@ApiBearerAuth(BEARER_AUTH_NAME)
@Controller('customers')
export class CustomersWriteController {
constructor(private readonly customersService: CustomersService) {}
@Post('import')
@RequirePrivilege(CUSTOMER_PRIVILEGE_KEYS, 'import')
@UseInterceptors(
FileInterceptor('file', {
limits: { fileSize: 1_048_576 },
fileFilter: (_req, file, cb) => {
if (!isAllowedCsvUpload(file)) {
cb(new BadRequestException('Only CSV files are allowed'), false);
return;
}
cb(null, true);
},
}),
)
@ApiConsumes('multipart/form-data')
@ApiBody({
schema: {
type: 'object',
properties: {
file: { type: 'string', format: 'binary' },
},
required: ['file'],
},
})
@ApiOperation({ summary: 'Import customers from CSV' })
@ApiOkResponse({
schema: { properties: { imported: { type: 'number' } } },
})
@ApiUnauthorizedResponse()
@ApiForbiddenResponse()
importCsv(
@UploadedFile() file: { buffer?: Buffer } | undefined,
@CurrentUser('id') userId: string,
): Promise<{ imported: number }> {
const csv = file?.buffer?.toString('utf8') ?? '';
return this.customersService.importCsv(csv, userId);
}
@Post('bulk-delete')
@HttpCode(200)
@RequirePrivilege(CUSTOMER_PRIVILEGE_KEYS, 'delete')
@ApiOperation({ summary: 'Bulk delete customers' })
@ApiOkResponse({
schema: { properties: { deleted: { type: 'number' } } },
})
@ApiUnauthorizedResponse()
@ApiForbiddenResponse()
bulkDelete(@Body() dto: BulkIdsDto): Promise<{ deleted: number }> {
return this.customersService.bulkDelete(dto.ids);
}
@Post('bulk-status')
@HttpCode(200)
@RequirePrivilege(CUSTOMER_PRIVILEGE_KEYS, 'update')
@ApiOperation({ summary: 'Bulk update customer status' })
@ApiOkResponse({
schema: { properties: { updated: { type: 'number' } } },
})
@ApiUnauthorizedResponse()
@ApiForbiddenResponse()
bulkStatus(
@Body() dto: BulkStatusDto,
@CurrentUser('id') userId: string,
): Promise<{ updated: number }> {
return this.customersService.bulkUpdateStatus(dto.ids, dto.status, userId);
}
@Post()
@RequirePrivilege(CUSTOMER_PRIVILEGE_KEYS, 'create')
@ApiOperation({ summary: 'Create customer' })
@ApiCreatedResponse({ type: CustomerDto })
@ApiUnauthorizedResponse()
@ApiForbiddenResponse()
create(
@Body() dto: CreateCustomerDto,
@CurrentUser('id') userId: string,
): Promise<CustomerDto> {
return this.customersService.create({
...dto,
userId,
});
}
@Post(':id/contacts')
@RequirePrivilege(CUSTOMER_PRIVILEGE_KEYS, 'update')
@ApiOperation({ summary: 'Add a customer contact' })
@ApiOkResponse({ type: CustomerDto })
@ApiNotFoundResponse()
@ApiUnauthorizedResponse()
@ApiForbiddenResponse()
addContact(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: CreateCustomerContactDto,
@CurrentUser('id') userId: string,
): Promise<CustomerDto> {
return this.customersService.addContact(id, dto, userId);
}
@Patch(':id/contacts/:contactId')
@RequirePrivilege(CUSTOMER_PRIVILEGE_KEYS, 'update')
@ApiOperation({ summary: 'Update a customer contact' })
@ApiOkResponse({ type: CustomerDto })
@ApiNotFoundResponse()
@ApiUnauthorizedResponse()
@ApiForbiddenResponse()
updateContact(
@Param('id', ParseUUIDPipe) id: string,
@Param('contactId', ParseUUIDPipe) contactId: string,
@Body() dto: UpdateCustomerContactDto,
@CurrentUser('id') userId: string,
): Promise<CustomerDto> {
return this.customersService.updateContact(id, contactId, {
...dto,
userId,
});
}
@Delete(':id/contacts/:contactId')
@HttpCode(204)
@RequirePrivilege(CUSTOMER_PRIVILEGE_KEYS, 'update')
@ApiOperation({ summary: 'Delete a customer contact' })
@ApiNoContentResponse()
@ApiNotFoundResponse()
@ApiUnauthorizedResponse()
@ApiForbiddenResponse()
async deleteContact(
@Param('id', ParseUUIDPipe) id: string,
@Param('contactId', ParseUUIDPipe) contactId: string,
): Promise<void> {
await this.customersService.deleteContact(id, contactId);
}
@Patch(':id/status')
@RequirePrivilege(CUSTOMER_PRIVILEGE_KEYS, 'update')
@ApiOperation({ summary: 'Update customer status' })
@ApiOkResponse({ type: CustomerDto })
@ApiNotFoundResponse()
@ApiUnauthorizedResponse()
@ApiForbiddenResponse()
updateStatus(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateCustomerStatusDto,
@CurrentUser('id') userId: string,
): Promise<CustomerDto> {
return this.customersService.updateStatus(id, dto.status, userId);
}
@Patch(':id')
@RequirePrivilege(CUSTOMER_PRIVILEGE_KEYS, 'update')
@ApiOperation({ summary: 'Update customer (not status)' })
@ApiOkResponse({ type: CustomerDto })
@ApiNotFoundResponse()
@ApiUnauthorizedResponse()
@ApiForbiddenResponse()
update(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateCustomerDto,
@CurrentUser('id') userId: string,
): Promise<CustomerDto> {
return this.customersService.update(id, {
...dto,
userId,
});
}
@Delete(':id')
@HttpCode(204)
@RequirePrivilege(CUSTOMER_PRIVILEGE_KEYS, 'delete')
@ApiOperation({ summary: 'Delete customer' })
@ApiNoContentResponse()
@ApiNotFoundResponse()
@ApiUnauthorizedResponse()
@ApiForbiddenResponse()
async delete(@Param('id', ParseUUIDPipe) id: string): Promise<void> {
await this.customersService.delete(id);
}
}
@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { EmployeesModule } from '../employees/employees.module';
import { TimelineModule } from '../../field/timeline/timeline.module';
import { CustomersReadController } from './customers-read.controller';
import { CustomersWriteController } from './customers-write.controller';
import { CustomersRepository } from './customers.repository';
import { CustomersService } from './customers.service';
@Module({
imports: [EmployeesModule, TimelineModule],
controllers: [CustomersReadController, CustomersWriteController],
providers: [CustomersRepository, CustomersService],
exports: [CustomersService],
})
export class CustomersModule {}
@@ -0,0 +1,238 @@
import { ConflictException, NotFoundException } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import { PhoneNumber } from '../../../common/value-objects/phone-number/phone-number';
import { Status } from '../../../common/value-objects/status/status';
import { DRIZZLE } from '../../../database/database.module';
import { CustomersRepository } from './customers.repository';
describe('CustomersRepository', () => {
let repository: CustomersRepository;
const limit = jest.fn();
const orderBy = jest.fn();
const offset = jest.fn();
const where = jest.fn();
const from = jest.fn();
const select = jest.fn();
const returning = jest.fn();
const values = jest.fn();
const insert = jest.fn();
const set = jest.fn();
const update = jest.fn();
const del = jest.fn();
const transaction = jest.fn();
const $dynamic = jest.fn();
const db = {
select,
insert,
update,
delete: del,
transaction,
};
const row = {
id: 'cu-1',
code: 'CUST_01',
name: 'Acme Corp',
phone: '+6281234567890',
address: 'Jl Sudirman No 1',
latitude: -6.2,
longitude: 106.8,
nfcId: 'NFC-001',
status: 'draft',
createdAt: 1_700_000_000_000,
updatedAt: 1_700_000_000_000,
createdBy: 'user-1',
updatedBy: 'user-1',
};
const contactRow = {
id: 'ct-1',
customerId: 'cu-1',
name: 'Jean Luc',
jobTitle: 'Buyer',
phone: '+6281234567891',
mobilePhone: null,
notes: 'Primary',
};
const createInput = {
code: 'CUST_01',
name: 'Acme Corp',
phone: PhoneNumber.create('+6281234567890'),
address: 'Jl Sudirman No 1',
userId: 'user-1',
};
beforeEach(async () => {
jest.clearAllMocks();
where.mockImplementation(() =>
Object.assign(Promise.resolve([{ id: 'user-1', username: 'admin' }]), {
limit,
orderBy,
returning,
}),
);
orderBy.mockImplementation(() => ({ limit }));
limit.mockImplementation(() => ({ offset }));
offset.mockResolvedValue([row]);
from.mockImplementation(() => ({
where,
$dynamic,
}));
$dynamic.mockReturnValue({ where });
select.mockImplementation(() => ({ from }));
values.mockReturnValue({ returning });
insert.mockReturnValue({ values });
set.mockReturnValue({ where });
update.mockReturnValue({ set });
del.mockReturnValue({ where });
returning.mockResolvedValue([row]);
transaction.mockImplementation((fn: (tx: typeof db) => unknown) => fn(db));
const moduleRef: TestingModule = await Test.createTestingModule({
providers: [CustomersRepository, { provide: DRIZZLE, useValue: db }],
}).compile();
repository = moduleRef.get(CustomersRepository);
});
it('findById maps a row and contacts to domain', async () => {
limit.mockResolvedValueOnce([row]);
orderBy.mockResolvedValueOnce([contactRow]);
const customer = await repository.findById('cu-1');
expect(customer).toMatchObject({
id: 'cu-1',
code: 'CUST_01',
createdBy: 'user-1',
});
expect(customer?.phone.value).toBe('+6281234567890');
expect(customer?.status.value).toBe('draft');
expect(customer?.contacts[0].name).toBe('Jean Luc');
expect(customer?.contacts[0].phone?.value).toBe('+6281234567891');
});
it('findById returns null when missing', async () => {
limit.mockResolvedValueOnce([]);
await expect(repository.findById('missing')).resolves.toBeNull();
});
it('list returns mapped rows and total without loading contacts', async () => {
select
.mockImplementationOnce(() => ({
from: () => ({
where: () => Promise.resolve([{ total: 1 }]),
}),
}))
.mockImplementationOnce(() => ({
from: () => ({
$dynamic: () => ({
where: () => ({
orderBy: () => ({
limit: () => ({
offset: () => Promise.resolve([row]),
}),
}),
}),
}),
}),
}));
const result = await repository.list({
name: 'Acme',
code: 'CUST',
search: 'sudirman',
limit: 10,
offset: 0,
});
expect(result.total).toBe(1);
expect(result.data[0].code).toBe('CUST_01');
expect(result.data[0].contacts).toEqual([]);
});
it('create inserts and maps unique violations', async () => {
returning.mockResolvedValue([row]);
orderBy.mockResolvedValueOnce([]);
const created = await repository.create(createInput);
expect(created.code).toBe('CUST_01');
transaction.mockRejectedValueOnce({ code: '23505' });
await expect(repository.create(createInput)).rejects.toBeInstanceOf(
ConflictException,
);
transaction.mockRejectedValueOnce({
code: '23505',
constraint: 'customers_nfc_id_unique',
});
await expect(repository.create(createInput)).rejects.toMatchObject({
message: 'Customer NFC ID already exists',
});
transaction.mockRejectedValueOnce({
cause: {
code: '23505',
constraint_name: 'customers_code_unique',
},
});
await expect(repository.create(createInput)).rejects.toMatchObject({
message: 'Customer code already exists',
});
});
it('create rethrows unknown errors', async () => {
transaction.mockRejectedValue(new Error('db down'));
await expect(repository.create(createInput)).rejects.toThrow('db down');
});
it('createMany returns 0 for an empty batch', async () => {
await expect(repository.createMany([])).resolves.toBe(0);
});
it('update throws when missing', async () => {
limit.mockResolvedValueOnce([]);
await expect(
repository.update('missing', { userId: 'user-1' }),
).rejects.toBeInstanceOf(NotFoundException);
});
it('updateStatus throws when missing', async () => {
returning.mockResolvedValueOnce([]);
await expect(
repository.updateStatus('missing', Status.create('active'), 'user-1'),
).rejects.toBeInstanceOf(NotFoundException);
});
it('delete throws when missing', async () => {
returning.mockResolvedValueOnce([]);
await expect(repository.delete('missing')).rejects.toBeInstanceOf(
NotFoundException,
);
});
it('bulkUpdateStatus and bulkDelete return 0 for empty ids', async () => {
await expect(
repository.bulkUpdateStatus([], Status.create('active'), 'user-1'),
).resolves.toBe(0);
await expect(repository.bulkDelete([])).resolves.toBe(0);
});
it('addContact throws when customer is missing', async () => {
limit.mockResolvedValueOnce([]);
await expect(
repository.addContact('missing', { name: 'Ada' }, 'user-1'),
).rejects.toBeInstanceOf(NotFoundException);
});
it('deleteContact throws when contact is missing', async () => {
returning.mockResolvedValueOnce([]);
await expect(
repository.deleteContact('cu-1', 'ct-1'),
).rejects.toBeInstanceOf(NotFoundException);
});
it('extendListQuery is a passthrough hook', () => {
const qb = { join: true };
expect(repository.extendListQuery(qb, { limit: 10, offset: 0 })).toBe(qb);
});
});
@@ -0,0 +1,539 @@
import {
ConflictException,
Inject,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { and, asc, count, eq, ilike, inArray, or, SQL } from 'drizzle-orm';
import { toOrderClauses } from '../../../common/http/response';
import { attachAuditUsers } from '../../../database/load-user-refs';
import { DateTime } from '../../../common/value-objects/date-time/date-time';
import { PhoneNumber } from '../../../common/value-objects/phone-number/phone-number';
import { Status } from '../../../common/value-objects/status/status';
import { DRIZZLE, type DrizzleDB } from '../../../database/database.module';
import {
customerContacts,
customers,
type CustomerContactRow,
type CustomerRow,
type NewCustomerRow,
} from '../../../database/customers-table';
import type {
CreateCustomerInput,
Customer,
CustomerContact,
CustomerContactInput,
ListCustomersFilters,
UpdateCustomerContactInput,
UpdateCustomerInput,
} from './customer';
const CUSTOMER_ORDER_COLUMNS = {
id: customers.id,
code: customers.code,
name: customers.name,
phone: customers.phone,
address: customers.address,
nfcId: customers.nfcId,
status: customers.status,
createdAt: customers.createdAt,
updatedAt: customers.updatedAt,
};
type QueryExecutor = Pick<DrizzleDB, 'select' | 'insert' | 'delete'>;
@Injectable()
export class CustomersRepository {
constructor(@Inject(DRIZZLE) private readonly db: DrizzleDB) {}
async list(
filters: ListCustomersFilters,
): Promise<{ data: Customer[]; total: number }> {
const where = this.buildListWhere(filters);
const totalRows = await this.db
.select({ total: count() })
.from(customers)
.where(where);
const totalRow = totalRows[0];
let qb = this.db.select().from(customers).$dynamic();
qb = this.extendListQuery(qb, filters);
const rows = await qb
.where(where)
.orderBy(
...toOrderClauses(CUSTOMER_ORDER_COLUMNS, filters, [
{ column: 'code', type: 'ASC' },
]),
)
.limit(filters.limit)
.offset(filters.offset);
return {
data: await Promise.all(rows.map((row) => this.hydrate(row, []))),
total: Number(totalRow?.total ?? 0),
};
}
/**
* Hook for modules to add joins/extra predicates without forking list.
*/
extendListQuery<T>(qb: T, filters: ListCustomersFilters): T {
void filters;
return qb;
}
async findById(id: string): Promise<Customer | null> {
const rows: CustomerRow[] = await this.db
.select()
.from(customers)
.where(eq(customers.id, id))
.limit(1);
const row = rows[0];
if (!row) {
return null;
}
const contacts = await this.selectContacts(this.db, id);
return this.hydrate(row, contacts);
}
async findByCode(code: string): Promise<Customer | null> {
const rows: CustomerRow[] = await this.db
.select()
.from(customers)
.where(eq(customers.code, code))
.limit(1);
const row = rows[0];
if (!row) {
return null;
}
const contacts = await this.selectContacts(this.db, row.id);
return this.hydrate(row, contacts);
}
async create(input: CreateCustomerInput): Promise<Customer> {
const now = DateTime.fromUnixMs(Date.now());
const status = input.status ?? Status.create(Status.DEFAULT);
try {
return await this.db.transaction(async (tx) => {
const inserted = await tx
.insert(customers)
.values(this.toInsertValues(input, status, now, input.userId))
.returning();
const row = inserted[0];
await this.replaceContacts(tx, row.id, input.contacts ?? []);
const contacts = await this.selectContacts(tx, row.id);
return this.hydrate(row, contacts);
});
} catch (error) {
this.rethrowConstraintViolation(error);
}
}
async createMany(inputs: CreateCustomerInput[]): Promise<number> {
if (inputs.length === 0) {
return 0;
}
const now = DateTime.fromUnixMs(Date.now());
try {
await this.db.transaction(async (tx) => {
for (const input of inputs) {
const status = input.status ?? Status.create(Status.DEFAULT);
const inserted = await tx
.insert(customers)
.values(this.toInsertValues(input, status, now, input.userId))
.returning();
const row = inserted[0];
await this.replaceContacts(tx, row.id, input.contacts ?? []);
}
});
return inputs.length;
} catch (error) {
this.rethrowConstraintViolation(error);
}
}
async update(id: string, input: UpdateCustomerInput): Promise<Customer> {
const existing = await this.findById(id);
if (!existing) {
throw new NotFoundException('Customer not found');
}
const now = DateTime.fromUnixMs(Date.now());
try {
return await this.db.transaction(async (tx) => {
const values: Partial<NewCustomerRow> = {
code: input.code ?? existing.code,
name: input.name ?? existing.name,
phone: input.phone?.value ?? existing.phone.value,
address: input.address ?? existing.address,
latitude:
input.latitude !== undefined ? input.latitude : existing.latitude,
longitude:
input.longitude !== undefined
? input.longitude
: existing.longitude,
nfcId: input.nfcId !== undefined ? input.nfcId : existing.nfcId,
updatedAt: now.value,
updatedBy: input.userId,
};
const updated = await tx
.update(customers)
.set(values)
.where(eq(customers.id, id))
.returning();
const row = updated[0];
if (!row) {
throw new NotFoundException('Customer not found');
}
if (input.contacts !== undefined) {
await this.replaceContacts(tx, id, input.contacts);
}
const contacts = await this.selectContacts(tx, id);
return this.hydrate(row, contacts);
});
} catch (error) {
this.rethrowConstraintViolation(error);
}
}
async updateStatus(
id: string,
status: Status,
userId: string,
): Promise<Customer> {
const now = DateTime.fromUnixMs(Date.now());
const updated = await this.db
.update(customers)
.set({
status: status.value,
updatedAt: now.value,
updatedBy: userId,
})
.where(eq(customers.id, id))
.returning();
const row = updated[0];
if (!row) {
throw new NotFoundException('Customer not found');
}
const contacts = await this.selectContacts(this.db, id);
return this.hydrate(row, contacts);
}
async bulkUpdateStatus(
ids: string[],
status: Status,
userId: string,
): Promise<number> {
if (ids.length === 0) {
return 0;
}
const now = DateTime.fromUnixMs(Date.now());
const rows = await this.db
.update(customers)
.set({
status: status.value,
updatedAt: now.value,
updatedBy: userId,
})
.where(inArray(customers.id, ids))
.returning({ id: customers.id });
return rows.length;
}
async delete(id: string): Promise<void> {
const deleted = await this.db
.delete(customers)
.where(eq(customers.id, id))
.returning({ id: customers.id });
if (deleted.length === 0) {
throw new NotFoundException('Customer not found');
}
}
async bulkDelete(ids: string[]): Promise<number> {
if (ids.length === 0) {
return 0;
}
const deleted = await this.db
.delete(customers)
.where(inArray(customers.id, ids))
.returning({ id: customers.id });
return deleted.length;
}
async addContact(
customerId: string,
input: CustomerContactInput,
userId: string,
): Promise<Customer> {
const existing = await this.findById(customerId);
if (!existing) {
throw new NotFoundException('Customer not found');
}
const now = DateTime.fromUnixMs(Date.now());
await this.db.insert(customerContacts).values({
customerId,
name: input.name,
jobTitle: input.jobTitle ?? null,
phone: input.phone?.value ?? null,
mobilePhone: input.mobilePhone?.value ?? null,
notes: input.notes ?? null,
});
await this.touchCustomer(customerId, userId, now);
const found = await this.findById(customerId);
if (!found) {
throw new NotFoundException('Customer not found');
}
return found;
}
async updateContact(
customerId: string,
contactId: string,
input: UpdateCustomerContactInput,
): Promise<Customer> {
const existing = await this.findById(customerId);
if (!existing) {
throw new NotFoundException('Customer not found');
}
const current = existing.contacts.find((c) => c.id === contactId);
if (!current) {
throw new NotFoundException('Contact not found');
}
const now = DateTime.fromUnixMs(Date.now());
const updated = await this.db
.update(customerContacts)
.set({
name: input.name ?? current.name,
jobTitle:
input.jobTitle !== undefined ? input.jobTitle : current.jobTitle,
phone:
input.phone !== undefined
? (input.phone?.value ?? null)
: (current.phone?.value ?? null),
mobilePhone:
input.mobilePhone !== undefined
? (input.mobilePhone?.value ?? null)
: (current.mobilePhone?.value ?? null),
notes: input.notes !== undefined ? input.notes : current.notes,
})
.where(
and(
eq(customerContacts.id, contactId),
eq(customerContacts.customerId, customerId),
),
)
.returning({ id: customerContacts.id });
if (updated.length === 0) {
throw new NotFoundException('Contact not found');
}
await this.touchCustomer(customerId, input.userId, now);
const found = await this.findById(customerId);
if (!found) {
throw new NotFoundException('Customer not found');
}
return found;
}
async deleteContact(customerId: string, contactId: string): Promise<void> {
const deleted = await this.db
.delete(customerContacts)
.where(
and(
eq(customerContacts.id, contactId),
eq(customerContacts.customerId, customerId),
),
)
.returning({ id: customerContacts.id });
if (deleted.length === 0) {
throw new NotFoundException('Contact not found');
}
}
private async touchCustomer(
customerId: string,
userId: string,
now: DateTime,
): Promise<void> {
await this.db
.update(customers)
.set({
updatedAt: now.value,
updatedBy: userId,
})
.where(eq(customers.id, customerId));
}
private async selectContacts(
executor: QueryExecutor,
customerId: string,
): Promise<CustomerContactRow[]> {
return executor
.select()
.from(customerContacts)
.where(eq(customerContacts.customerId, customerId))
.orderBy(asc(customerContacts.name));
}
private async replaceContacts(
executor: QueryExecutor,
customerId: string,
contacts: readonly CustomerContactInput[],
): Promise<void> {
await executor
.delete(customerContacts)
.where(eq(customerContacts.customerId, customerId));
if (contacts.length === 0) {
return;
}
await executor.insert(customerContacts).values(
contacts.map((contact) => ({
customerId,
name: contact.name,
jobTitle: contact.jobTitle ?? null,
phone: contact.phone?.value ?? null,
mobilePhone: contact.mobilePhone?.value ?? null,
notes: contact.notes ?? null,
})),
);
}
private buildListWhere(filters: ListCustomersFilters): SQL | undefined {
const parts: SQL[] = [];
if (filters.code) {
parts.push(ilike(customers.code, `%${filters.code}%`));
}
if (filters.name) {
parts.push(ilike(customers.name, `%${filters.name}%`));
}
if (filters.phone) {
parts.push(ilike(customers.phone, `%${filters.phone}%`));
}
if (filters.address) {
parts.push(ilike(customers.address, `%${filters.address}%`));
}
if (filters.nfcId) {
parts.push(eq(customers.nfcId, filters.nfcId));
}
if (filters.status) {
parts.push(eq(customers.status, filters.status));
}
if (filters.search) {
const search = or(
ilike(customers.code, `%${filters.search}%`),
ilike(customers.name, `%${filters.search}%`),
ilike(customers.address, `%${filters.search}%`),
);
if (search) {
parts.push(search);
}
}
if (parts.length === 0) {
return undefined;
}
return parts.length === 1 ? parts[0] : and(...parts);
}
private toInsertValues(
input: CreateCustomerInput,
status: Status,
now: DateTime,
userId: string,
) {
return {
code: input.code,
name: input.name,
phone: input.phone.value,
address: input.address,
latitude: input.latitude ?? null,
longitude: input.longitude ?? null,
nfcId: input.nfcId ?? null,
status: status.value,
createdAt: now.value,
updatedAt: now.value,
createdBy: userId,
updatedBy: userId,
};
}
private async hydrate(
row: CustomerRow,
contactRows: CustomerContactRow[],
): Promise<Customer> {
const [item] = await attachAuditUsers(this.db, [
this.toDomain(row, contactRows),
]);
return item;
}
private toDomain(row: CustomerRow, contactRows: CustomerContactRow[]) {
return {
id: row.id,
code: row.code,
name: row.name,
phone: PhoneNumber.create(row.phone),
address: row.address,
latitude: row.latitude,
longitude: row.longitude,
nfcId: row.nfcId,
contacts: contactRows.map((contact) => this.toContactDomain(contact)),
status: Status.create(row.status),
createdAt: DateTime.fromUnixMs(row.createdAt),
updatedAt: DateTime.fromUnixMs(row.updatedAt),
createdBy: row.createdBy,
updatedBy: row.updatedBy,
};
}
private toContactDomain(row: CustomerContactRow): CustomerContact {
return {
id: row.id,
customerId: row.customerId,
name: row.name,
jobTitle: row.jobTitle,
phone: row.phone ? PhoneNumber.create(row.phone) : null,
mobilePhone: row.mobilePhone ? PhoneNumber.create(row.mobilePhone) : null,
notes: row.notes,
};
}
private rethrowConstraintViolation(error: unknown): never {
if (error instanceof NotFoundException) {
throw error;
}
const err = this.unwrapDbError(error);
if (err.code === '23505') {
const constraint = err.constraint ?? '';
if (constraint.includes('nfc')) {
throw new ConflictException('Customer NFC ID already exists');
}
throw new ConflictException('Customer code already exists');
}
throw error;
}
private unwrapDbError(error: unknown): {
code?: string;
constraint?: string;
} {
let current: unknown = error;
for (let i = 0; i < 5; i++) {
if (!current || typeof current !== 'object') {
break;
}
const obj = current as {
code?: string;
constraint?: string;
constraint_name?: string;
cause?: unknown;
};
if (obj.code === '23505' || obj.code === '23503') {
return {
code: obj.code,
constraint: obj.constraint ?? obj.constraint_name,
};
}
current = obj.cause;
}
return error as { code?: string; constraint?: string };
}
}
@@ -0,0 +1,279 @@
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import { DateTime } from '../../../common/value-objects/date-time/date-time';
import { PhoneNumber } from '../../../common/value-objects/phone-number/phone-number';
import { Status } from '../../../common/value-objects/status/status';
import type { Customer } from './customer';
import { CustomersRepository } from './customers.repository';
import { CustomersService } from './customers.service';
describe('CustomersService', () => {
let service: CustomersService;
let repository: jest.Mocked<
Pick<
CustomersRepository,
| 'list'
| 'findById'
| 'create'
| 'createMany'
| 'update'
| 'updateStatus'
| 'bulkUpdateStatus'
| 'delete'
| 'bulkDelete'
| 'addContact'
| 'updateContact'
| 'deleteContact'
>
>;
const now = DateTime.fromUnixMs(1_700_000_000_000);
const sample: Customer = {
id: 'cu-1',
code: 'CUST_01',
name: 'Acme Corp',
phone: PhoneNumber.create('+6281234567890'),
address: 'Jl Sudirman No 1',
latitude: -6.2,
longitude: 106.8,
nfcId: 'NFC-001',
contacts: [
{
id: 'ct-1',
customerId: 'cu-1',
name: 'Jean Luc',
jobTitle: 'Buyer',
phone: PhoneNumber.create('+6281234567891'),
mobilePhone: null,
notes: 'Primary',
},
],
status: Status.create('draft'),
createdAt: now,
updatedAt: now,
createdBy: 'user-1',
createdByUser: { id: 'user-1', username: 'admin' },
updatedByUser: { id: 'user-1', username: 'admin' },
updatedBy: 'user-1',
};
const createInput = {
code: 'CUST_01',
name: 'Acme Corp',
phone: '+6281234567890',
address: 'Jl Sudirman No 1',
userId: 'user-1',
};
beforeEach(async () => {
repository = {
list: jest.fn(),
findById: jest.fn(),
create: jest.fn(),
createMany: jest.fn(),
update: jest.fn(),
updateStatus: jest.fn(),
bulkUpdateStatus: jest.fn(),
delete: jest.fn(),
bulkDelete: jest.fn(),
addContact: jest.fn(),
updateContact: jest.fn(),
deleteContact: jest.fn(),
};
const moduleRef: TestingModule = await Test.createTestingModule({
providers: [
CustomersService,
{ provide: CustomersRepository, useValue: repository },
],
}).compile();
service = moduleRef.get(CustomersService);
});
it('list maps visible fields without contacts', async () => {
repository.list.mockResolvedValue({ data: [sample], total: 1 });
const result = await service.list({ page: 1, limit: 10 });
expect(result.total).toBe(1);
expect(result.data[0]).toMatchObject({
id: 'cu-1',
code: 'CUST_01',
name: 'Acme Corp',
phone: '+6281234567890',
status: 'draft',
});
expect(result.data[0]).not.toHaveProperty('contacts');
expect(service.visibleFields).toContain('phone');
});
it('findById throws when missing', async () => {
repository.findById.mockResolvedValue(null);
await expect(service.findById('missing')).rejects.toBeInstanceOf(
NotFoundException,
);
});
it('findById returns mapped item with contacts', async () => {
repository.findById.mockResolvedValue(sample);
const result = await service.findById('cu-1');
expect(result.id).toBe('cu-1');
expect(result.contacts).toHaveLength(1);
expect(result.contacts[0].name).toBe('Jean Luc');
expect(result.contacts[0].phone).toBe('+6281234567891');
});
it('create defaults status to draft and maps contacts', async () => {
repository.create.mockResolvedValue(sample);
await service.create({
...createInput,
contacts: [{ name: 'Jean Luc', phone: '+6281234567891' }],
});
const arg = repository.create.mock.calls[0][0];
expect(arg.status?.value).toBe('draft');
expect(arg.phone.value).toBe('+6281234567890');
expect(arg.contacts?.[0].name).toBe('Jean Luc');
expect(arg.contacts?.[0].phone?.value).toBe('+6281234567891');
});
it('create rejects invalid phone without echoing input', async () => {
await expect(
service.create({ ...createInput, phone: '081234567890' }),
).rejects.toBeInstanceOf(BadRequestException);
expect(repository.create).not.toHaveBeenCalled();
});
it('create rejects invalid name or code', async () => {
await expect(
service.create({ ...createInput, name: 'Acme1' }),
).rejects.toBeInstanceOf(BadRequestException);
await expect(
service.create({ ...createInput, code: 'CUST 01' }),
).rejects.toBeInstanceOf(BadRequestException);
});
it('create rejects out of range coordinates', async () => {
await expect(
service.create({ ...createInput, latitude: 91 }),
).rejects.toBeInstanceOf(BadRequestException);
await expect(
service.create({ ...createInput, longitude: 181 }),
).rejects.toBeInstanceOf(BadRequestException);
});
it('update rejects status field', async () => {
await expect(
service.update('cu-1', { status: 'active', userId: 'user-1' }),
).rejects.toBeInstanceOf(BadRequestException);
});
it('update replaces contacts when contacts is sent', async () => {
repository.update.mockResolvedValue(sample);
await service.update('cu-1', {
contacts: [{ name: 'Ada Lovelace' }],
userId: 'user-1',
});
expect(repository.update).toHaveBeenCalledWith(
'cu-1',
expect.objectContaining({
contacts: [expect.objectContaining({ name: 'Ada Lovelace' })],
}),
);
});
it('update leaves contacts unchanged when omitted', async () => {
repository.update.mockResolvedValue(sample);
await service.update('cu-1', { name: 'Acme Corp', userId: 'user-1' });
expect(repository.update).toHaveBeenCalledWith(
'cu-1',
expect.objectContaining({
name: 'Acme Corp',
contacts: undefined,
}),
);
});
it('updateStatus updates via repository', async () => {
repository.updateStatus.mockResolvedValue(sample);
await service.updateStatus('cu-1', 'active', 'user-1');
expect(repository.updateStatus).toHaveBeenCalledWith(
'cu-1',
expect.objectContaining({ value: 'active' }),
'user-1',
);
});
it('delete, bulkDelete, and bulkUpdateStatus delegate', async () => {
repository.delete.mockResolvedValue(undefined);
repository.bulkDelete.mockResolvedValue(2);
repository.bulkUpdateStatus.mockResolvedValue(2);
await service.delete('cu-1');
await expect(service.bulkDelete(['a', 'b'])).resolves.toEqual({
deleted: 2,
});
await expect(
service.bulkUpdateStatus(['a', 'b'], 'archived', 'user-1'),
).resolves.toEqual({ updated: 2 });
});
it('addContact and updateContact validate phones', async () => {
repository.addContact.mockResolvedValue(sample);
repository.updateContact.mockResolvedValue(sample);
await service.addContact('cu-1', { name: 'Ada Lovelace' }, 'user-1');
expect(repository.addContact).toHaveBeenCalledWith(
'cu-1',
expect.objectContaining({ name: 'Ada Lovelace' }),
'user-1',
);
await expect(
service.addContact('cu-1', { name: 'Ada', phone: '0812' }, 'user-1'),
).rejects.toBeInstanceOf(BadRequestException);
});
it('deleteContact delegates', async () => {
repository.deleteContact.mockResolvedValue(undefined);
await service.deleteContact('cu-1', 'ct-1');
expect(repository.deleteContact).toHaveBeenCalledWith('cu-1', 'ct-1');
});
it('importCsv imports valid rows without contacts', async () => {
repository.createMany.mockResolvedValue(1);
const csv =
'code,name,phone,address,status\n' +
'CUST_01,Acme Corp,+6281234567890,Jl Sudirman No 1,draft';
const result = await service.importCsv(csv, 'user-1');
expect(result.imported).toBe(1);
expect(repository.createMany).toHaveBeenCalledTimes(1);
expect(repository.createMany.mock.calls[0][0][0].contacts).toEqual([]);
});
it('importCsv fails the batch on invalid phone', async () => {
const csv =
'code,name,phone,address\n' + 'CUST_01,Acme Corp,081234,Jl Sudirman No 1';
await expect(service.importCsv(csv, 'user-1')).rejects.toBeInstanceOf(
BadRequestException,
);
expect(repository.createMany).not.toHaveBeenCalled();
});
it('importCsv rejects empty and headerless files', async () => {
await expect(service.importCsv('', 'user-1')).rejects.toBeInstanceOf(
BadRequestException,
);
await expect(
service.importCsv('code,name\nCUST_01,Acme', 'user-1'),
).rejects.toBeInstanceOf(BadRequestException);
});
it('importCsv rejects oversized files', async () => {
const huge = [
'code,name,phone,address',
...Array.from(
{ length: 501 },
(_, i) => `C${i},Acme Corp,+6281234567890,Jl Sudirman`,
),
].join('\n');
await expect(service.importCsv(huge, 'user-1')).rejects.toBeInstanceOf(
BadRequestException,
);
});
});
@@ -0,0 +1,542 @@
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import type { PaginationResponse } from '../../../common/http/response';
import { pickUserRelation, toListPage } from '../../../common/http/response';
import { InvalidPhoneNumberError } from '../../../common/value-objects/phone-number/invalid-phone-number.error';
import { PhoneNumber } from '../../../common/value-objects/phone-number/phone-number';
import { Status } from '../../../common/value-objects/status/status';
import type {
CreateCustomerInput,
Customer,
CustomerContact,
CustomerContactInput,
UpdateCustomerContactInput,
UpdateCustomerInput,
} from './customer';
import {
isValidContactJobTitle,
isValidContactName,
isValidContactNotes,
isValidCustomerAddress,
isValidCustomerCode,
isValidCustomerName,
isValidLatitude,
isValidLongitude,
isValidNfcId,
parseCsvRecord,
} from './customer-fields';
import { CustomersRepository } from './customers.repository';
import { EmployeesService } from '../employees/employees.service';
import { TimelineActivitiesService } from '../../field/timeline/timeline-activities.service';
export type ListCustomersQuery = {
readonly code?: string;
readonly name?: string;
readonly phone?: string;
readonly address?: string;
readonly nfcId?: string;
readonly status?: string;
readonly search?: string;
readonly orderBy?: string;
readonly orderType?: string;
readonly page?: number;
readonly limit?: number;
readonly offset?: number;
};
export type ContactBody = {
readonly name: string;
readonly jobTitle?: string | null;
readonly phone?: string | null;
readonly mobilePhone?: string | null;
readonly notes?: string | null;
};
const VISIBLE_FIELDS = [
'id',
'code',
'name',
'phone',
'address',
'latitude',
'longitude',
'nfcId',
'status',
'createdAt',
'updatedAt',
'createdBy',
'updatedBy',
] as const;
const CSV_REQUIRED_HEADERS = ['code', 'name', 'phone', 'address'] as const;
@Injectable()
export class CustomersService {
constructor(
private readonly customersRepository: CustomersRepository,
private readonly employeesService: EmployeesService,
private readonly timelineActivitiesService: TimelineActivitiesService,
) {}
async list(
query: ListCustomersQuery,
): Promise<PaginationResponse<ReturnType<CustomersService['toListItem']>>> {
const page = toListPage(query);
const { data, total } = await this.customersRepository.list({
code: query.code,
name: query.name,
phone: query.phone,
address: query.address,
nfcId: query.nfcId,
status: query.status,
search: query.search,
orderBy: query.orderBy,
orderType: query.orderType,
limit: page.limit,
offset: page.offset,
});
return {
data: data.map((item) => this.toListItem(item)),
total,
};
}
async findById(
id: string,
): Promise<ReturnType<CustomersService['toDetail']>> {
const customer = await this.customersRepository.findById(id);
if (!customer) {
throw new NotFoundException('Customer not found');
}
return this.toDetail(customer);
}
async findByCode(
code: string,
): Promise<ReturnType<CustomersService['toDetail']>> {
const customer = await this.customersRepository.findByCode(code);
if (!customer) {
throw new NotFoundException('Customer not found');
}
return this.toDetail(customer);
}
async create(input: {
code: string;
name: string;
phone: string;
address: string;
latitude?: number | null;
longitude?: number | null;
nfcId?: string | null;
status?: string;
contacts?: ContactBody[];
userId: string;
}): Promise<ReturnType<CustomersService['toDetail']>> {
const created = await this.customersRepository.create(
this.toCreateInput(input),
);
const employee = await this.employeesService.requireByUserId(input.userId);
await this.timelineActivitiesService.recordIfLocated({
employeeId: employee.id,
type: 'customer_created',
sourceType: 'customer',
sourceId: created.id,
latitude: created.latitude,
longitude: created.longitude,
customerId: created.id,
});
return this.toDetail(created);
}
async update(
id: string,
input: {
code?: string;
name?: string;
phone?: string;
address?: string;
latitude?: number | null;
longitude?: number | null;
nfcId?: string | null;
contacts?: ContactBody[];
status?: unknown;
userId: string;
},
): Promise<ReturnType<CustomersService['toDetail']>> {
if (input.status !== undefined) {
throw new BadRequestException('status cannot be updated via PATCH');
}
const payload: UpdateCustomerInput = {
code: input.code !== undefined ? this.assertCode(input.code) : undefined,
name: input.name !== undefined ? this.assertName(input.name) : undefined,
phone:
input.phone !== undefined ? this.assertPhone(input.phone) : undefined,
address:
input.address !== undefined
? this.assertAddress(input.address)
: undefined,
latitude:
input.latitude !== undefined
? this.assertLatitude(input.latitude)
: undefined,
longitude:
input.longitude !== undefined
? this.assertLongitude(input.longitude)
: undefined,
nfcId:
input.nfcId !== undefined ? this.assertNfcId(input.nfcId) : undefined,
contacts:
input.contacts !== undefined
? input.contacts.map((contact) => this.assertContact(contact))
: undefined,
userId: input.userId,
};
const updated = await this.customersRepository.update(id, payload);
return this.toDetail(updated);
}
async updateStatus(
id: string,
statusRaw: string,
userId: string,
): Promise<ReturnType<CustomersService['toDetail']>> {
const status = Status.create(statusRaw);
const updated = await this.customersRepository.updateStatus(
id,
status,
userId,
);
return this.toDetail(updated);
}
async bulkUpdateStatus(
ids: string[],
statusRaw: string,
userId: string,
): Promise<{ updated: number }> {
const status = Status.create(statusRaw);
const updated = await this.customersRepository.bulkUpdateStatus(
ids,
status,
userId,
);
return { updated };
}
async delete(id: string): Promise<void> {
await this.customersRepository.delete(id);
}
async bulkDelete(ids: string[]): Promise<{ deleted: number }> {
const deleted = await this.customersRepository.bulkDelete(ids);
return { deleted };
}
async addContact(
customerId: string,
body: ContactBody,
userId: string,
): Promise<ReturnType<CustomersService['toDetail']>> {
const updated = await this.customersRepository.addContact(
customerId,
this.assertContact(body),
userId,
);
return this.toDetail(updated);
}
async updateContact(
customerId: string,
contactId: string,
body: Partial<ContactBody> & { userId: string },
): Promise<ReturnType<CustomersService['toDetail']>> {
const payload: UpdateCustomerContactInput = {
name:
body.name !== undefined ? this.assertContactName(body.name) : undefined,
jobTitle:
body.jobTitle !== undefined
? this.assertOptionalJobTitle(body.jobTitle)
: undefined,
phone:
body.phone !== undefined
? this.assertOptionalPhone(body.phone)
: undefined,
mobilePhone:
body.mobilePhone !== undefined
? this.assertOptionalPhone(body.mobilePhone)
: undefined,
notes:
body.notes !== undefined
? this.assertOptionalNotes(body.notes)
: undefined,
userId: body.userId,
};
const updated = await this.customersRepository.updateContact(
customerId,
contactId,
payload,
);
return this.toDetail(updated);
}
async deleteContact(customerId: string, contactId: string): Promise<void> {
await this.customersRepository.deleteContact(customerId, contactId);
}
async importCsv(csv: string, userId: string): Promise<{ imported: number }> {
const rawLines = csv.split(/\r?\n/);
const filled = rawLines
.map((line, index) => ({ line: line.trim(), lineNo: index + 1 }))
.filter((entry) => entry.line.length > 0);
if (filled.length === 0) {
throw new BadRequestException('CSV is empty');
}
if (filled.length > 501) {
throw new BadRequestException('CSV exceeds maximum of 500 data rows');
}
const header = parseCsvRecord(filled[0].line).map((h) =>
h.trim().toLowerCase(),
);
const missing = CSV_REQUIRED_HEADERS.filter((h) => header.indexOf(h) < 0);
if (missing.length > 0) {
throw new BadRequestException('CSV must include required headers');
}
const idx = (key: string) => header.indexOf(key);
const errors: string[] = [];
const rows: CreateCustomerInput[] = [];
for (let i = 1; i < filled.length; i++) {
const cols = parseCsvRecord(filled[i].line);
const rowNum = filled[i].lineNo;
try {
const statusRaw = idx('status') >= 0 ? cols[idx('status')] : '';
const latitudeRaw =
idx('latitude') >= 0 ? cols[idx('latitude')] : undefined;
const longitudeRaw =
idx('longitude') >= 0 ? cols[idx('longitude')] : undefined;
const nfcRaw = idx('nfcid') >= 0 ? cols[idx('nfcid')] : undefined;
rows.push(
this.toCreateInput({
code: cols[idx('code')] ?? '',
name: cols[idx('name')] ?? '',
phone: cols[idx('phone')] ?? '',
address: cols[idx('address')] ?? '',
latitude:
latitudeRaw === undefined || latitudeRaw === ''
? undefined
: Number(latitudeRaw),
longitude:
longitudeRaw === undefined || longitudeRaw === ''
? undefined
: Number(longitudeRaw),
nfcId: nfcRaw || undefined,
status: statusRaw || undefined,
userId,
}),
);
} catch (error) {
const reason =
error instanceof BadRequestException ? error.message : 'invalid data';
errors.push(`row ${rowNum}: ${reason}`);
}
}
if (errors.length > 0) {
throw new BadRequestException({
message: 'CSV validation failed',
errors,
});
}
await this.customersRepository.createMany(rows);
return { imported: rows.length };
}
toListItem(customer: Customer) {
return {
id: customer.id,
code: customer.code,
name: customer.name,
phone: customer.phone.value,
address: customer.address,
latitude: customer.latitude,
longitude: customer.longitude,
nfcId: customer.nfcId,
status: customer.status.value,
createdAt: customer.createdAt.value,
updatedAt: customer.updatedAt.value,
createdBy: pickUserRelation(customer.createdByUser),
updatedBy: pickUserRelation(customer.updatedByUser),
};
}
toDetail(customer: Customer) {
return {
...this.toListItem(customer),
contacts: customer.contacts.map((contact) => this.toContactItem(contact)),
};
}
get visibleFields(): readonly string[] {
return VISIBLE_FIELDS;
}
private toContactItem(contact: CustomerContact) {
return {
id: contact.id,
customerId: contact.customerId,
name: contact.name,
jobTitle: contact.jobTitle,
phone: contact.phone?.value ?? null,
mobilePhone: contact.mobilePhone?.value ?? null,
notes: contact.notes,
};
}
private toCreateInput(input: {
code: string;
name: string;
phone: string;
address: string;
latitude?: number | null;
longitude?: number | null;
nfcId?: string | null;
status?: string;
contacts?: ContactBody[];
userId: string;
}): CreateCustomerInput {
return {
code: this.assertCode(input.code),
name: this.assertName(input.name),
phone: this.assertPhone(input.phone),
address: this.assertAddress(input.address),
latitude: this.assertLatitude(input.latitude ?? null),
longitude: this.assertLongitude(input.longitude ?? null),
nfcId: this.assertNfcId(input.nfcId ?? null),
contacts: (input.contacts ?? []).map((contact) =>
this.assertContact(contact),
),
status: input.status
? Status.create(input.status)
: Status.create(Status.DEFAULT),
userId: input.userId,
};
}
private assertContact(raw: ContactBody): CustomerContactInput {
return {
name: this.assertContactName(raw.name),
jobTitle: this.assertOptionalJobTitle(raw.jobTitle ?? null),
phone: this.assertOptionalPhone(raw.phone ?? null),
mobilePhone: this.assertOptionalPhone(raw.mobilePhone ?? null),
notes: this.assertOptionalNotes(raw.notes ?? null),
};
}
private assertName(raw: string): string {
const name = raw.trim();
if (!isValidCustomerName(name)) {
throw new BadRequestException('Invalid customer name');
}
return name;
}
private assertCode(raw: string): string {
const code = raw.trim();
if (!isValidCustomerCode(code)) {
throw new BadRequestException('Invalid customer code');
}
return code;
}
private assertAddress(raw: string): string {
const address = raw.trim();
if (!isValidCustomerAddress(address)) {
throw new BadRequestException('Invalid customer address');
}
return address;
}
private assertPhone(raw: string): PhoneNumber {
try {
return PhoneNumber.create(raw);
} catch (error) {
if (error instanceof InvalidPhoneNumberError) {
throw new BadRequestException('Invalid phone number');
}
throw error;
}
}
private assertOptionalPhone(raw: string | null): PhoneNumber | null {
if (raw === null || raw.trim() === '') {
return null;
}
return this.assertPhone(raw);
}
private assertLatitude(raw: number | null): number | null {
if (raw === null) {
return null;
}
if (!isValidLatitude(raw)) {
throw new BadRequestException('Invalid latitude');
}
return raw;
}
private assertLongitude(raw: number | null): number | null {
if (raw === null) {
return null;
}
if (!isValidLongitude(raw)) {
throw new BadRequestException('Invalid longitude');
}
return raw;
}
private assertNfcId(raw: string | null): string | null {
if (raw === null || raw.trim() === '') {
return null;
}
const value = raw.trim();
if (!isValidNfcId(value)) {
throw new BadRequestException('Invalid NFC ID');
}
return value;
}
private assertContactName(raw: string): string {
const name = raw.trim();
if (!isValidContactName(name)) {
throw new BadRequestException('Invalid contact name');
}
return name;
}
private assertOptionalJobTitle(raw: string | null): string | null {
if (raw === null || raw.trim() === '') {
return null;
}
const value = raw.trim();
if (!isValidContactJobTitle(value)) {
throw new BadRequestException('Invalid contact job title');
}
return value;
}
private assertOptionalNotes(raw: string | null): string | null {
if (raw === null) {
return null;
}
if (!isValidContactNotes(raw)) {
throw new BadRequestException('Invalid contact notes');
}
return raw;
}
}
@@ -0,0 +1,358 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
ArrayNotEmpty,
IsArray,
IsIn,
IsNotEmpty,
IsNumber,
IsOptional,
IsString,
IsUUID,
Matches,
Max,
MaxLength,
Min,
ValidateNested,
} from 'class-validator';
import {
PaginationQueryDto,
UserRelationDto,
} from '../../../../common/http/response';
import { CORE_STATUSES } from '../../../../common/value-objects/status/status';
import {
CONTACT_JOB_TITLE_MAX_LENGTH,
CONTACT_NAME_MAX_LENGTH,
CONTACT_NOTES_MAX_LENGTH,
CUSTOMER_ADDRESS_MAX_LENGTH,
CUSTOMER_CODE_MAX_LENGTH,
CUSTOMER_CODE_PATTERN,
CUSTOMER_NAME_MAX_LENGTH,
CUSTOMER_NAME_PATTERN,
CUSTOMER_NFC_ID_MAX_LENGTH,
} from '../customer-fields';
export class CreateCustomerContactDto {
@ApiProperty({ example: 'Jean Luc', maxLength: CONTACT_NAME_MAX_LENGTH })
@IsString()
@IsNotEmpty()
@MaxLength(CONTACT_NAME_MAX_LENGTH)
name!: string;
@ApiPropertyOptional({ example: 'Purchasing Manager' })
@IsOptional()
@IsString()
@MaxLength(CONTACT_JOB_TITLE_MAX_LENGTH)
jobTitle?: string;
@ApiPropertyOptional({ example: '+6281234567890' })
@IsOptional()
@IsString()
@IsNotEmpty()
phone?: string;
@ApiPropertyOptional({ example: '+6281234567891' })
@IsOptional()
@IsString()
@IsNotEmpty()
mobilePhone?: string;
@ApiPropertyOptional({ example: 'Call after 9am' })
@IsOptional()
@IsString()
@MaxLength(CONTACT_NOTES_MAX_LENGTH)
notes?: string;
}
export class UpdateCustomerContactDto {
@ApiPropertyOptional({ example: 'Jean Luc' })
@IsOptional()
@IsString()
@IsNotEmpty()
@MaxLength(CONTACT_NAME_MAX_LENGTH)
name?: string;
@ApiPropertyOptional({ example: 'Purchasing Manager', nullable: true })
@IsOptional()
@IsString()
@MaxLength(CONTACT_JOB_TITLE_MAX_LENGTH)
jobTitle?: string | null;
@ApiPropertyOptional({ example: '+6281234567890', nullable: true })
@IsOptional()
@IsString()
phone?: string | null;
@ApiPropertyOptional({ example: '+6281234567891', nullable: true })
@IsOptional()
@IsString()
mobilePhone?: string | null;
@ApiPropertyOptional({ example: 'Call after 9am', nullable: true })
@IsOptional()
@IsString()
@MaxLength(CONTACT_NOTES_MAX_LENGTH)
notes?: string | null;
}
export class CustomerContactDto {
@ApiProperty({ format: 'uuid' })
id!: string;
@ApiProperty({ format: 'uuid' })
customerId!: string;
@ApiProperty()
name!: string;
@ApiPropertyOptional({ nullable: true })
jobTitle!: string | null;
@ApiPropertyOptional({ nullable: true })
phone!: string | null;
@ApiPropertyOptional({ nullable: true })
mobilePhone!: string | null;
@ApiPropertyOptional({ nullable: true })
notes!: string | null;
}
export class CreateCustomerDto {
@ApiProperty({ example: 'CUST_01', maxLength: CUSTOMER_CODE_MAX_LENGTH })
@IsString()
@IsNotEmpty()
@MaxLength(CUSTOMER_CODE_MAX_LENGTH)
@Matches(CUSTOMER_CODE_PATTERN, {
message: 'code must contain only letters, numbers, and underscores',
})
code!: string;
@ApiProperty({ example: 'Acme Corp', maxLength: CUSTOMER_NAME_MAX_LENGTH })
@IsString()
@IsNotEmpty()
@MaxLength(CUSTOMER_NAME_MAX_LENGTH)
@Matches(CUSTOMER_NAME_PATTERN, {
message: 'name must contain only letters and spaces',
})
name!: string;
@ApiProperty({ example: '+6281234567890' })
@IsString()
@IsNotEmpty()
phone!: string;
@ApiProperty({ example: 'Jl Sudirman No 1' })
@IsString()
@IsNotEmpty()
@MaxLength(CUSTOMER_ADDRESS_MAX_LENGTH)
address!: string;
@ApiPropertyOptional({ example: -6.2 })
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(-90)
@Max(90)
latitude?: number;
@ApiPropertyOptional({ example: 106.8 })
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(-180)
@Max(180)
longitude?: number;
@ApiPropertyOptional({ example: 'NFC-001' })
@IsOptional()
@IsString()
@IsNotEmpty()
@MaxLength(CUSTOMER_NFC_ID_MAX_LENGTH)
nfcId?: string;
@ApiPropertyOptional({ enum: CORE_STATUSES })
@IsOptional()
@IsIn([...CORE_STATUSES])
status?: string;
@ApiPropertyOptional({ type: [CreateCustomerContactDto] })
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => CreateCustomerContactDto)
contacts?: CreateCustomerContactDto[];
}
export class UpdateCustomerDto {
@ApiPropertyOptional({ example: 'CUST_01' })
@IsOptional()
@IsString()
@IsNotEmpty()
@MaxLength(CUSTOMER_CODE_MAX_LENGTH)
@Matches(CUSTOMER_CODE_PATTERN, {
message: 'code must contain only letters, numbers, and underscores',
})
code?: string;
@ApiPropertyOptional({ example: 'Acme Corp' })
@IsOptional()
@IsString()
@IsNotEmpty()
@MaxLength(CUSTOMER_NAME_MAX_LENGTH)
@Matches(CUSTOMER_NAME_PATTERN, {
message: 'name must contain only letters and spaces',
})
name?: string;
@ApiPropertyOptional({ example: '+6281234567890' })
@IsOptional()
@IsString()
@IsNotEmpty()
phone?: string;
@ApiPropertyOptional({ example: 'Jl Sudirman No 1' })
@IsOptional()
@IsString()
@IsNotEmpty()
@MaxLength(CUSTOMER_ADDRESS_MAX_LENGTH)
address?: string;
@ApiPropertyOptional({ example: -6.2, nullable: true })
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(-90)
@Max(90)
latitude?: number | null;
@ApiPropertyOptional({ example: 106.8, nullable: true })
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(-180)
@Max(180)
longitude?: number | null;
@ApiPropertyOptional({ example: 'NFC-001', nullable: true })
@IsOptional()
@IsString()
@MaxLength(CUSTOMER_NFC_ID_MAX_LENGTH)
nfcId?: string | null;
@ApiPropertyOptional({ type: [CreateCustomerContactDto] })
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => CreateCustomerContactDto)
contacts?: CreateCustomerContactDto[];
}
export class UpdateCustomerStatusDto {
@ApiProperty({ enum: CORE_STATUSES })
@IsIn([...CORE_STATUSES])
status!: string;
}
export class BulkIdsDto {
@ApiProperty({ type: [String], format: 'uuid' })
@IsArray()
@ArrayNotEmpty()
@IsUUID('4', { each: true })
ids!: string[];
}
export class BulkStatusDto {
@ApiProperty({ type: [String], format: 'uuid' })
@IsArray()
@ArrayNotEmpty()
@IsUUID('4', { each: true })
ids!: string[];
@ApiProperty({ enum: CORE_STATUSES })
@IsIn([...CORE_STATUSES])
status!: string;
}
export class ListCustomersQueryDto extends PaginationQueryDto {
@ApiPropertyOptional()
@IsOptional()
@IsString()
code?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
name?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
phone?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
address?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
nfcId?: string;
@ApiPropertyOptional({ enum: CORE_STATUSES })
@IsOptional()
@IsIn([...CORE_STATUSES])
status?: string;
@ApiPropertyOptional({
description: 'Case-insensitive match on code, name, or address',
})
@IsOptional()
@IsString()
search?: string;
}
export class CustomerDto {
@ApiProperty({ format: 'uuid' })
id!: string;
@ApiProperty()
code!: string;
@ApiProperty()
name!: string;
@ApiProperty({ example: '+6281234567890' })
phone!: string;
@ApiProperty()
address!: string;
@ApiPropertyOptional({ nullable: true })
latitude!: number | null;
@ApiPropertyOptional({ nullable: true })
longitude!: number | null;
@ApiPropertyOptional({ nullable: true })
nfcId!: string | null;
@ApiPropertyOptional({ type: [CustomerContactDto] })
contacts?: CustomerContactDto[];
@ApiProperty({ enum: CORE_STATUSES })
status!: string;
@ApiProperty({ description: 'Unix ms' })
createdAt!: number;
@ApiProperty({ description: 'Unix ms' })
updatedAt!: number;
@ApiProperty({ type: UserRelationDto })
createdBy!: UserRelationDto;
@ApiProperty({ type: UserRelationDto })
updatedBy!: UserRelationDto;
}
@@ -1,3 +1,4 @@
import type { UserRelation } from '../../../common/http/response';
import { DateTime } from '../../../common/value-objects/date-time/date-time';
import { Status } from '../../../common/value-objects/status/status';
@@ -10,6 +11,8 @@ export type Division = {
readonly updatedAt: DateTime;
readonly createdBy: string;
readonly updatedBy: string;
readonly createdByUser: UserRelation;
readonly updatedByUser: UserRelation;
};
export type CreateDivisionInput = {
@@ -30,6 +33,8 @@ export type ListDivisionsFilters = {
readonly code?: string;
readonly status?: string;
readonly search?: string;
readonly orderBy?: string;
readonly orderType?: string;
readonly limit: number;
readonly offset: number;
};

Some files were not shown because too many files have changed in this diff Show More