13 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
171 changed files with 9072 additions and 659 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`).
+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).
+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
+30
View File
@@ -83,3 +83,33 @@ 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.
+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);
+35
View File
@@ -92,6 +92,41 @@
"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
+2
View File
@@ -7,6 +7,7 @@ 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';
@@ -24,6 +25,7 @@ import { UsersModule } from './modules/users/users.module';
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) {
+1
View File
@@ -47,3 +47,4 @@ export {
type OrderDefault,
type OrderType,
} from './order-clause';
export { parseQueryIdList } from './parse-query-id-list';
@@ -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;
}
+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'),
};
+6 -1
View File
@@ -1,4 +1,4 @@
import { bigint, pgTable, uuid } from 'drizzle-orm/pg-core';
import { bigint, integer, pgTable, uuid } from 'drizzle-orm/pg-core';
import { primaryEntityColumns } from './primary-entity-columns';
import { users } from './schema';
@@ -8,6 +8,11 @@ import { users } from './schema';
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),
});
+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;
+4
View File
@@ -1,5 +1,6 @@
import {
bigint,
doublePrecision,
index,
numeric,
pgTable,
@@ -44,6 +45,9 @@ export const salesInvoices = pgTable(
}),
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),
},
+23
View File
@@ -247,3 +247,26 @@ export {
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;
+14 -2
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,6 +14,9 @@ describe('AuthController', () => {
let privilegesService: jest.Mocked<
Pick<PrivilegesService, 'findPrivilegeSummary' | 'getPermissionsMap'>
>;
let employeesService: jest.Mocked<
Pick<EmployeesService, 'findRelationByUserId'>
>;
beforeEach(async () => {
authService = {
@@ -43,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],
@@ -50,6 +57,7 @@ describe('AuthController', () => {
{ provide: AuthService, useValue: authService },
{ provide: UsersService, useValue: usersService },
{ provide: PrivilegesService, useValue: privilegesService },
{ provide: EmployeesService, useValue: employeesService },
],
}).compile();
@@ -91,6 +99,7 @@ describe('AuthController', () => {
username: 'alice',
isSuperadmin: false,
privilege: null,
employee: null,
permissions: {},
});
});
@@ -109,7 +118,7 @@ describe('AuthController', () => {
status: 'active',
});
privilegesService.getPermissionsMap.mockResolvedValue({
PRIVILEGES: {
'ADMIN.SETTINGS.USER.PRIVILEGES': {
view: true,
create: true,
update: true,
@@ -128,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,
}),
},
});
});
@@ -153,6 +164,7 @@ describe('AuthController', () => {
username: 'alice',
isSuperadmin: true,
privilege: null,
employee: null,
permissions: {},
});
});
+6
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 {
@@ -35,6 +36,7 @@ export class AuthController {
private readonly authService: AuthService,
private readonly usersService: UsersService,
private readonly privilegesService: PrivilegesService,
private readonly employeesService: EmployeesService,
) {}
@Public()
@@ -97,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: {},
};
}
@@ -119,6 +124,7 @@ export class AuthController {
username: user.username,
isSuperadmin,
privilege,
employee,
permissions,
};
}
+2
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({
+18 -1
View File
@@ -90,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;
@@ -103,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,
@@ -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)
@@ -18,7 +18,10 @@ 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_KEY = 'CONFIGURATION.CUSTOMER';
export const CUSTOMER_PRIVILEGE_KEYS = [
'ADMIN.SETTINGS.DATA.CUSTOMER',
'MOBILE.SALES.CUSTOMER',
] as const;
@ApiTags('customers')
@ApiBearerAuth(BEARER_AUTH_NAME)
@@ -28,7 +31,7 @@ export class CustomersReadController {
@Get()
@Pagination()
@RequirePrivilege(CUSTOMER_PRIVILEGE_KEY, 'view')
@RequirePrivilege(CUSTOMER_PRIVILEGE_KEYS, 'view')
@ApiOperation({ summary: 'List customers' })
@ApiOkResponse({
schema: {
@@ -50,7 +53,7 @@ export class CustomersReadController {
}
@Get(':id')
@RequirePrivilege(CUSTOMER_PRIVILEGE_KEY, 'view')
@RequirePrivilege(CUSTOMER_PRIVILEGE_KEYS, 'view')
@ApiOperation({ summary: 'Get customer detail' })
@ApiOkResponse({ type: CustomerDto })
@ApiNotFoundResponse()
@@ -29,7 +29,7 @@ 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_KEY } from './customers-read.controller';
import { CUSTOMER_PRIVILEGE_KEYS } from './customers-read.controller';
import { CustomersService } from './customers.service';
import {
BulkIdsDto,
@@ -49,7 +49,7 @@ export class CustomersWriteController {
constructor(private readonly customersService: CustomersService) {}
@Post('import')
@RequirePrivilege(CUSTOMER_PRIVILEGE_KEY, 'import')
@RequirePrivilege(CUSTOMER_PRIVILEGE_KEYS, 'import')
@UseInterceptors(
FileInterceptor('file', {
limits: { fileSize: 1_048_576 },
@@ -88,7 +88,7 @@ export class CustomersWriteController {
@Post('bulk-delete')
@HttpCode(200)
@RequirePrivilege(CUSTOMER_PRIVILEGE_KEY, 'delete')
@RequirePrivilege(CUSTOMER_PRIVILEGE_KEYS, 'delete')
@ApiOperation({ summary: 'Bulk delete customers' })
@ApiOkResponse({
schema: { properties: { deleted: { type: 'number' } } },
@@ -101,7 +101,7 @@ export class CustomersWriteController {
@Post('bulk-status')
@HttpCode(200)
@RequirePrivilege(CUSTOMER_PRIVILEGE_KEY, 'update')
@RequirePrivilege(CUSTOMER_PRIVILEGE_KEYS, 'update')
@ApiOperation({ summary: 'Bulk update customer status' })
@ApiOkResponse({
schema: { properties: { updated: { type: 'number' } } },
@@ -116,7 +116,7 @@ export class CustomersWriteController {
}
@Post()
@RequirePrivilege(CUSTOMER_PRIVILEGE_KEY, 'create')
@RequirePrivilege(CUSTOMER_PRIVILEGE_KEYS, 'create')
@ApiOperation({ summary: 'Create customer' })
@ApiCreatedResponse({ type: CustomerDto })
@ApiUnauthorizedResponse()
@@ -132,7 +132,7 @@ export class CustomersWriteController {
}
@Post(':id/contacts')
@RequirePrivilege(CUSTOMER_PRIVILEGE_KEY, 'update')
@RequirePrivilege(CUSTOMER_PRIVILEGE_KEYS, 'update')
@ApiOperation({ summary: 'Add a customer contact' })
@ApiOkResponse({ type: CustomerDto })
@ApiNotFoundResponse()
@@ -147,7 +147,7 @@ export class CustomersWriteController {
}
@Patch(':id/contacts/:contactId')
@RequirePrivilege(CUSTOMER_PRIVILEGE_KEY, 'update')
@RequirePrivilege(CUSTOMER_PRIVILEGE_KEYS, 'update')
@ApiOperation({ summary: 'Update a customer contact' })
@ApiOkResponse({ type: CustomerDto })
@ApiNotFoundResponse()
@@ -167,7 +167,7 @@ export class CustomersWriteController {
@Delete(':id/contacts/:contactId')
@HttpCode(204)
@RequirePrivilege(CUSTOMER_PRIVILEGE_KEY, 'update')
@RequirePrivilege(CUSTOMER_PRIVILEGE_KEYS, 'update')
@ApiOperation({ summary: 'Delete a customer contact' })
@ApiNoContentResponse()
@ApiNotFoundResponse()
@@ -181,7 +181,7 @@ export class CustomersWriteController {
}
@Patch(':id/status')
@RequirePrivilege(CUSTOMER_PRIVILEGE_KEY, 'update')
@RequirePrivilege(CUSTOMER_PRIVILEGE_KEYS, 'update')
@ApiOperation({ summary: 'Update customer status' })
@ApiOkResponse({ type: CustomerDto })
@ApiNotFoundResponse()
@@ -196,7 +196,7 @@ export class CustomersWriteController {
}
@Patch(':id')
@RequirePrivilege(CUSTOMER_PRIVILEGE_KEY, 'update')
@RequirePrivilege(CUSTOMER_PRIVILEGE_KEYS, 'update')
@ApiOperation({ summary: 'Update customer (not status)' })
@ApiOkResponse({ type: CustomerDto })
@ApiNotFoundResponse()
@@ -215,7 +215,7 @@ export class CustomersWriteController {
@Delete(':id')
@HttpCode(204)
@RequirePrivilege(CUSTOMER_PRIVILEGE_KEY, 'delete')
@RequirePrivilege(CUSTOMER_PRIVILEGE_KEYS, 'delete')
@ApiOperation({ summary: 'Delete customer' })
@ApiNoContentResponse()
@ApiNotFoundResponse()
@@ -1,10 +1,13 @@
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],
@@ -29,6 +29,8 @@ import {
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;
@@ -73,7 +75,11 @@ const CSV_REQUIRED_HEADERS = ['code', 'name', 'phone', 'address'] as const;
@Injectable()
export class CustomersService {
constructor(private readonly customersRepository: CustomersRepository) {}
constructor(
private readonly customersRepository: CustomersRepository,
private readonly employeesService: EmployeesService,
private readonly timelineActivitiesService: TimelineActivitiesService,
) {}
async list(
query: ListCustomersQuery,
@@ -133,6 +139,16 @@ export class CustomersService {
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);
}
@@ -18,7 +18,7 @@ import { BEARER_AUTH_NAME } from '../../../common/swagger/setup-swagger';
import { DivisionDto, ListDivisionsQueryDto } from './dto/division.dto';
import { DivisionsService } from './divisions.service';
export const DIVISION_PRIVILEGE_KEY = 'CONFIGURATION.DIVISION';
export const DIVISION_PRIVILEGE_KEY = 'ADMIN.SETTINGS.DATA.DIVISION';
@ApiTags('divisions')
@ApiBearerAuth(BEARER_AUTH_NAME)
@@ -18,7 +18,7 @@ import { BEARER_AUTH_NAME } from '../../../common/swagger/setup-swagger';
import { EmployeeDto, ListEmployeesQueryDto } from './dto/employee.dto';
import { EmployeesService } from './employees.service';
export const EMPLOYEE_PRIVILEGE_KEY = 'CONFIGURATION.EMPLOYEE';
export const EMPLOYEE_PRIVILEGE_KEY = 'ADMIN.SALES.DATA.EMPLOYEE';
@ApiTags('employees')
@ApiBearerAuth(BEARER_AUTH_NAME)
@@ -220,6 +220,26 @@ describe('EmployeesRepository', () => {
);
});
it('delete maps foreign-key violations to ConflictException', async () => {
returning.mockRejectedValueOnce({ code: '23503' });
await expect(repository.delete('emp-1')).rejects.toMatchObject({
constructor: ConflictException,
message: 'Employee is referenced by other records',
});
returning.mockRejectedValueOnce({
cause: { code: '23503' },
});
await expect(repository.delete('emp-1')).rejects.toBeInstanceOf(
ConflictException,
);
});
it('delete rethrows unknown errors', async () => {
returning.mockRejectedValueOnce(new Error('db down'));
await expect(repository.delete('emp-1')).rejects.toThrow('db down');
});
it('bulkUpdateStatus and bulkDelete return 0 for empty ids', async () => {
await expect(
repository.bulkUpdateStatus([], Status.create('active'), 'user-1'),
@@ -240,6 +260,16 @@ describe('EmployeesRepository', () => {
await expect(repository.bulkDelete(['emp-1'])).resolves.toBe(1);
});
it('bulkDelete maps foreign-key violations to ConflictException', async () => {
returning.mockRejectedValueOnce({
cause: { code: '23503' },
});
await expect(repository.bulkDelete(['emp-1'])).rejects.toMatchObject({
constructor: ConflictException,
message: 'Employee is referenced by other records',
});
});
it('extendListQuery is a passthrough hook', () => {
const qb = { join: true };
expect(repository.extendListQuery(qb, { limit: 10, offset: 0 })).toBe(qb);
@@ -221,12 +221,16 @@ export class EmployeesRepository {
}
async delete(id: string): Promise<void> {
const deleted = await this.db
.delete(employees)
.where(eq(employees.id, id))
.returning({ id: employees.id });
if (deleted.length === 0) {
throw new NotFoundException('Employee not found');
try {
const deleted = await this.db
.delete(employees)
.where(eq(employees.id, id))
.returning({ id: employees.id });
if (deleted.length === 0) {
throw new NotFoundException('Employee not found');
}
} catch (error) {
this.rethrowForeignKeyViolation(error);
}
}
@@ -234,11 +238,15 @@ export class EmployeesRepository {
if (ids.length === 0) {
return 0;
}
const deleted = await this.db
.delete(employees)
.where(inArray(employees.id, ids))
.returning({ id: employees.id });
return deleted.length;
try {
const deleted = await this.db
.delete(employees)
.where(inArray(employees.id, ids))
.returning({ id: employees.id });
return deleted.length;
} catch (error) {
this.rethrowForeignKeyViolation(error);
}
}
private buildListWhere(filters: ListEmployeesFilters): SQL | undefined {
@@ -363,6 +371,14 @@ export class EmployeesRepository {
throw error;
}
private rethrowForeignKeyViolation(error: unknown): never {
const err = this.unwrapDbError(error);
if (err.code === '23503') {
throw new ConflictException('Employee is referenced by other records');
}
throw error;
}
private unwrapDbError(error: unknown): {
code?: string;
constraint?: string;
@@ -117,6 +117,32 @@ export class EmployeesService {
return this.toListItem(employee);
}
async findRelationByUserId(userId: string): Promise<{
id: string;
code: string;
name: string;
position: string;
} | null> {
const employee = await this.employeesRepository.findByUserId(userId);
if (!employee) {
return null;
}
return {
id: employee.id,
code: employee.code,
name: employee.name,
position: employee.position,
};
}
async requireByUserId(userId: string): Promise<Employee> {
const employee = await this.employeesRepository.findByUserId(userId);
if (!employee) {
throw new BadRequestException('User is not linked to an employee');
}
return employee;
}
async create(input: {
code: string;
name: string;
@@ -18,7 +18,7 @@ import { BEARER_AUTH_NAME } from '../../../common/swagger/setup-swagger';
import { ProductDto, ListProductsQueryDto } from './dto/product.dto';
import { ProductsService } from './products.service';
export const PRODUCT_PRIVILEGE_KEY = 'CONFIGURATION.PRODUCT';
export const PRODUCT_PRIVILEGE_KEY = 'ADMIN.SETTINGS.DATA.PRODUCT';
@ApiTags('products')
@ApiBearerAuth(BEARER_AUTH_NAME)
@@ -0,0 +1,71 @@
import { DateTime } from '../../../common/value-objects/date-time/date-time';
import { Status } from '../../../common/value-objects/status/status';
import type { CheckInMethod } from '../shared/check-in-verification';
export type Attendance = {
readonly id: string;
readonly employeeId: string;
readonly branchId: string;
readonly date: DateTime;
readonly checkInAt: DateTime;
readonly checkInMethod: CheckInMethod;
readonly checkInLatitude: number;
readonly checkInLongitude: number;
readonly checkInPhotoUrl: string | null;
readonly checkInDistanceMeters: number | null;
readonly checkOutAt: DateTime | null;
readonly checkOutMethod: CheckInMethod | null;
readonly checkOutLatitude: number | null;
readonly checkOutLongitude: number | null;
readonly checkOutPhotoUrl: string | null;
readonly checkOutDistanceMeters: number | null;
readonly status: Status;
readonly createdAt: DateTime;
readonly updatedAt: DateTime;
readonly createdBy: string;
readonly updatedBy: string;
readonly employee: { id: string; code: string; name: string } | null;
readonly branch: {
id: string;
code: string;
name: string;
division: { id: string; code: string; name: string } | null;
} | null;
readonly createdByUser: { id: string; username: string } | null;
readonly updatedByUser: { id: string; username: string } | null;
};
export type ListAttendancesFilters = {
readonly employeeId?: string;
readonly branchId?: string;
readonly date?: number;
readonly status?: string;
readonly search?: string;
readonly orderBy?: string;
readonly orderType?: string;
readonly limit: number;
readonly offset: number;
};
export type CreateAttendanceInput = {
readonly employeeId: string;
readonly branchId: string;
readonly date: DateTime;
readonly checkInAt: DateTime;
readonly checkInMethod: CheckInMethod;
readonly checkInLatitude: number;
readonly checkInLongitude: number;
readonly checkInPhotoUrl: string | null;
readonly checkInDistanceMeters: number | null;
readonly userId: string;
};
export type CheckOutAttendanceInput = {
readonly checkOutAt: DateTime;
readonly checkOutMethod: CheckInMethod;
readonly checkOutLatitude: number;
readonly checkOutLongitude: number;
readonly checkOutPhotoUrl: string | null;
readonly checkOutDistanceMeters: number | null;
readonly userId: string;
};
@@ -0,0 +1,77 @@
import { Controller, Get, Param, ParseUUIDPipe, Query } from '@nestjs/common';
import {
ApiBearerAuth,
ApiForbiddenResponse,
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 {
Pagination,
type PaginationResponse,
PaginationMetaDto,
} from '../../../common/http/response';
import { BEARER_AUTH_NAME } from '../../../common/swagger/setup-swagger';
import { FIELD_ATTENDANCE_PRIVILEGE_KEY } from '../shared/field-purpose';
import { AttendanceDto, ListAttendancesQueryDto } from './dto/attendance.dto';
import { AttendancesService } from './attendances.service';
@ApiTags('attendances')
@ApiBearerAuth(BEARER_AUTH_NAME)
@Controller('attendances')
export class AttendancesReadController {
constructor(private readonly attendancesService: AttendancesService) {}
@Get('current')
@RequirePrivilege(FIELD_ATTENDANCE_PRIVILEGE_KEY, 'view')
@ApiOperation({ summary: 'Get the open attendance for the current user' })
@ApiOkResponse({
type: AttendanceDto,
description: 'Null when no open shift',
})
@ApiUnauthorizedResponse()
@ApiForbiddenResponse()
current(@CurrentUser('id') userId: string): Promise<AttendanceDto | null> {
return this.attendancesService.findCurrent(userId);
}
@Get()
@Pagination()
@RequirePrivilege(FIELD_ATTENDANCE_PRIVILEGE_KEY, 'view')
@ApiOperation({ summary: 'List attendances' })
@ApiOkResponse({
schema: {
properties: {
data: {
type: 'array',
items: { $ref: '#/components/schemas/AttendanceDto' },
},
meta: { $ref: '#/components/schemas/PaginationMetaDto' },
},
},
})
@ApiUnauthorizedResponse()
@ApiForbiddenResponse()
list(
@Query() query: ListAttendancesQueryDto,
): Promise<PaginationResponse<AttendanceDto>> {
return this.attendancesService.list(query);
}
@Get(':id')
@RequirePrivilege(FIELD_ATTENDANCE_PRIVILEGE_KEY, 'view')
@ApiOperation({ summary: 'Get attendance detail' })
@ApiOkResponse({ type: AttendanceDto })
@ApiNotFoundResponse()
@ApiUnauthorizedResponse()
@ApiForbiddenResponse()
findOne(@Param('id', ParseUUIDPipe) id: string): Promise<AttendanceDto> {
return this.attendancesService.findById(id);
}
}
void PaginationMetaDto;
@@ -0,0 +1,116 @@
import {
Body,
Controller,
Delete,
HttpCode,
Param,
ParseUUIDPipe,
Patch,
Post,
} from '@nestjs/common';
import {
ApiBearerAuth,
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 { FIELD_ATTENDANCE_PRIVILEGE_KEY } from '../shared/field-purpose';
import {
AttendanceCheckInDto,
AttendanceCheckOutDto,
AttendanceDto,
BulkIdsDto,
BulkStatusDto,
UpdateAttendanceStatusDto,
} from './dto/attendance.dto';
import { AttendancesService } from './attendances.service';
@ApiTags('attendances')
@ApiBearerAuth(BEARER_AUTH_NAME)
@Controller('attendances')
export class AttendancesWriteController {
constructor(private readonly attendancesService: AttendancesService) {}
@Post('check-in')
@RequirePrivilege(FIELD_ATTENDANCE_PRIVILEGE_KEY, 'create')
@ApiOperation({ summary: 'Check in at a branch' })
@ApiCreatedResponse({ type: AttendanceDto })
@ApiUnauthorizedResponse()
@ApiForbiddenResponse()
checkIn(
@Body() dto: AttendanceCheckInDto,
@CurrentUser('id') userId: string,
): Promise<AttendanceDto> {
return this.attendancesService.checkIn(dto, userId);
}
@Post('bulk-delete')
@HttpCode(200)
@RequirePrivilege(FIELD_ATTENDANCE_PRIVILEGE_KEY, 'delete')
@ApiOperation({ summary: 'Bulk delete attendances' })
@ApiOkResponse({ schema: { properties: { deleted: { type: 'number' } } } })
bulkDelete(@Body() dto: BulkIdsDto): Promise<{ deleted: number }> {
return this.attendancesService.bulkDelete(dto.ids);
}
@Post('bulk-status')
@HttpCode(200)
@RequirePrivilege(FIELD_ATTENDANCE_PRIVILEGE_KEY, 'update')
@ApiOperation({ summary: 'Bulk update attendance status' })
@ApiOkResponse({ schema: { properties: { updated: { type: 'number' } } } })
bulkStatus(
@Body() dto: BulkStatusDto,
@CurrentUser('id') userId: string,
): Promise<{ updated: number }> {
return this.attendancesService.bulkUpdateStatus(
dto.ids,
dto.status,
userId,
);
}
@Post(':id/check-out')
@RequirePrivilege(FIELD_ATTENDANCE_PRIVILEGE_KEY, 'update')
@ApiOperation({ summary: 'Check out from a branch shift' })
@ApiOkResponse({ type: AttendanceDto })
@ApiNotFoundResponse()
@ApiUnauthorizedResponse()
@ApiForbiddenResponse()
checkOut(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: AttendanceCheckOutDto,
@CurrentUser('id') userId: string,
): Promise<AttendanceDto> {
return this.attendancesService.checkOut(id, dto, userId);
}
@Patch(':id/status')
@RequirePrivilege(FIELD_ATTENDANCE_PRIVILEGE_KEY, 'update')
@ApiOperation({ summary: 'Update attendance status' })
@ApiOkResponse({ type: AttendanceDto })
updateStatus(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateAttendanceStatusDto,
@CurrentUser('id') userId: string,
): Promise<AttendanceDto> {
return this.attendancesService.updateStatus(id, dto.status, userId);
}
@Delete(':id')
@HttpCode(204)
@RequirePrivilege(FIELD_ATTENDANCE_PRIVILEGE_KEY, 'delete')
@ApiOperation({ summary: 'Delete attendance' })
@ApiNoContentResponse()
@ApiNotFoundResponse()
delete(@Param('id', ParseUUIDPipe) id: string): Promise<void> {
return this.attendancesService.delete(id);
}
}
@@ -0,0 +1,344 @@
import {
ConflictException,
Inject,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { and, count, eq, ilike, inArray, isNull, or, SQL } from 'drizzle-orm';
import { alias } from 'drizzle-orm/pg-core';
import { toOrderClauses } from '../../../common/http/response/order-clause';
import { DateTime } from '../../../common/value-objects/date-time/date-time';
import { Status } from '../../../common/value-objects/status/status';
import { DRIZZLE, type DrizzleDB } from '../../../database/database.module';
import {
attendances,
type AttendanceRow,
} from '../../../database/attendances-table';
import { branches } from '../../../database/branches-table';
import { divisions, employees, users } from '../../../database/schema';
import type {
Attendance,
CheckOutAttendanceInput,
CreateAttendanceInput,
ListAttendancesFilters,
} from './attendance';
import type { CheckInMethod } from '../shared/check-in-verification';
const ATTENDANCE_ORDER_COLUMNS = {
id: attendances.id,
date: attendances.date,
status: attendances.status,
createdAt: attendances.createdAt,
updatedAt: attendances.updatedAt,
};
const createdByUsers = alias(users, 'attendance_created_by_users');
const updatedByUsers = alias(users, 'attendance_updated_by_users');
type AttendanceJoinedRow = {
attendance: AttendanceRow;
employee: typeof employees.$inferSelect;
branch: typeof branches.$inferSelect;
division: typeof divisions.$inferSelect | null;
createdByUser: typeof users.$inferSelect | null;
updatedByUser: typeof users.$inferSelect | null;
};
@Injectable()
export class AttendancesRepository {
constructor(@Inject(DRIZZLE) private readonly db: DrizzleDB) {}
async list(
filters: ListAttendancesFilters,
): Promise<{ data: Attendance[]; total: number }> {
const where = this.buildListWhere(filters);
const totalRows = await this.db
.select({ total: count() })
.from(attendances)
.where(where);
const totalRow = totalRows[0];
const rows = await this.selectWithRelations()
.where(where)
.orderBy(
...toOrderClauses(ATTENDANCE_ORDER_COLUMNS, filters, [
{ column: 'date', type: 'DESC' },
]),
)
.limit(filters.limit)
.offset(filters.offset);
return {
data: rows.map((row) => this.toDomain(row)),
total: Number(totalRow?.total ?? 0),
};
}
async findById(id: string): Promise<Attendance | null> {
const rows = await this.selectWithRelations()
.where(eq(attendances.id, id))
.limit(1);
const row = rows[0];
return row ? this.toDomain(row) : null;
}
async findOpenByEmployeeId(employeeId: string): Promise<Attendance | null> {
const rows = await this.selectWithRelations()
.where(
and(
eq(attendances.employeeId, employeeId),
isNull(attendances.checkOutAt),
),
)
.limit(1);
const row = rows[0];
return row ? this.toDomain(row) : null;
}
async create(input: CreateAttendanceInput): Promise<Attendance> {
const now = DateTime.fromUnixMs(Date.now());
try {
const inserted = await this.db
.insert(attendances)
.values({
employeeId: input.employeeId,
branchId: input.branchId,
date: input.date.value,
checkInAt: input.checkInAt.value,
checkInMethod: input.checkInMethod,
checkInLatitude: input.checkInLatitude,
checkInLongitude: input.checkInLongitude,
checkInPhotoUrl: input.checkInPhotoUrl,
checkInDistanceMeters: input.checkInDistanceMeters,
status: Status.create('active').value,
createdAt: now.value,
updatedAt: now.value,
createdBy: input.userId,
updatedBy: input.userId,
})
.returning();
const created = await this.findById(inserted[0].id);
if (!created) {
throw new NotFoundException('Attendance not found');
}
return created;
} catch (error) {
this.rethrowUniqueViolation(error);
}
}
async checkOut(
id: string,
input: CheckOutAttendanceInput,
): Promise<Attendance> {
const existing = await this.findById(id);
if (!existing) {
throw new NotFoundException('Attendance not found');
}
if (existing.checkOutAt) {
throw new ConflictException('Attendance is already checked out');
}
const now = DateTime.fromUnixMs(Date.now());
await this.db
.update(attendances)
.set({
checkOutAt: input.checkOutAt.value,
checkOutMethod: input.checkOutMethod,
checkOutLatitude: input.checkOutLatitude,
checkOutLongitude: input.checkOutLongitude,
checkOutPhotoUrl: input.checkOutPhotoUrl,
checkOutDistanceMeters: input.checkOutDistanceMeters,
updatedAt: now.value,
updatedBy: input.userId,
})
.where(eq(attendances.id, id));
const updated = await this.findById(id);
if (!updated) {
throw new NotFoundException('Attendance not found');
}
return updated;
}
async updateStatus(
id: string,
status: Status,
userId: string,
): Promise<Attendance> {
const now = DateTime.fromUnixMs(Date.now());
const updated = await this.db
.update(attendances)
.set({
status: status.value,
updatedAt: now.value,
updatedBy: userId,
})
.where(eq(attendances.id, id))
.returning({ id: attendances.id });
if (updated.length === 0) {
throw new NotFoundException('Attendance not found');
}
const row = await this.findById(id);
if (!row) {
throw new NotFoundException('Attendance not found');
}
return row;
}
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(attendances)
.set({
status: status.value,
updatedAt: now.value,
updatedBy: userId,
})
.where(inArray(attendances.id, ids))
.returning({ id: attendances.id });
return rows.length;
}
async delete(id: string): Promise<void> {
const deleted = await this.db
.delete(attendances)
.where(eq(attendances.id, id))
.returning({ id: attendances.id });
if (deleted.length === 0) {
throw new NotFoundException('Attendance not found');
}
}
async bulkDelete(ids: string[]): Promise<number> {
if (ids.length === 0) {
return 0;
}
const deleted = await this.db
.delete(attendances)
.where(inArray(attendances.id, ids))
.returning({ id: attendances.id });
return deleted.length;
}
private selectWithRelations() {
return this.db
.select({
attendance: attendances,
employee: employees,
branch: branches,
division: divisions,
createdByUser: createdByUsers,
updatedByUser: updatedByUsers,
})
.from(attendances)
.innerJoin(employees, eq(attendances.employeeId, employees.id))
.innerJoin(branches, eq(attendances.branchId, branches.id))
.leftJoin(divisions, eq(branches.divisionId, divisions.id))
.leftJoin(createdByUsers, eq(attendances.createdBy, createdByUsers.id))
.leftJoin(updatedByUsers, eq(attendances.updatedBy, updatedByUsers.id));
}
private buildListWhere(filters: ListAttendancesFilters): SQL | undefined {
const parts: SQL[] = [];
if (filters.employeeId) {
parts.push(eq(attendances.employeeId, filters.employeeId));
}
if (filters.branchId) {
parts.push(eq(attendances.branchId, filters.branchId));
}
if (filters.date !== undefined) {
parts.push(eq(attendances.date, filters.date));
}
if (filters.status) {
parts.push(eq(attendances.status, filters.status));
}
if (filters.search) {
const search = or(
ilike(employees.code, `%${filters.search}%`),
ilike(employees.name, `%${filters.search}%`),
ilike(branches.code, `%${filters.search}%`),
ilike(branches.name, `%${filters.search}%`),
);
if (search) {
parts.push(search);
}
}
if (parts.length === 0) {
return undefined;
}
return parts.length === 1 ? parts[0] : and(...parts);
}
private toDomain(row: AttendanceJoinedRow): Attendance {
const attendance = row.attendance;
return {
id: attendance.id,
employeeId: attendance.employeeId,
branchId: attendance.branchId,
date: DateTime.fromUnixMs(attendance.date),
checkInAt: DateTime.fromUnixMs(attendance.checkInAt),
checkInMethod: attendance.checkInMethod as CheckInMethod,
checkInLatitude: attendance.checkInLatitude,
checkInLongitude: attendance.checkInLongitude,
checkInPhotoUrl: attendance.checkInPhotoUrl,
checkInDistanceMeters: attendance.checkInDistanceMeters,
checkOutAt: attendance.checkOutAt
? DateTime.fromUnixMs(attendance.checkOutAt)
: null,
checkOutMethod: attendance.checkOutMethod as CheckInMethod | null,
checkOutLatitude: attendance.checkOutLatitude,
checkOutLongitude: attendance.checkOutLongitude,
checkOutPhotoUrl: attendance.checkOutPhotoUrl,
checkOutDistanceMeters: attendance.checkOutDistanceMeters,
status: Status.create(attendance.status),
createdAt: DateTime.fromUnixMs(attendance.createdAt),
updatedAt: DateTime.fromUnixMs(attendance.updatedAt),
createdBy: attendance.createdBy,
updatedBy: attendance.updatedBy,
employee: {
id: row.employee.id,
code: row.employee.code,
name: row.employee.name,
},
branch: {
id: row.branch.id,
code: row.branch.code,
name: row.branch.name,
division: row.division
? {
id: row.division.id,
code: row.division.code,
name: row.division.name,
}
: null,
},
createdByUser: row.createdByUser
? { id: row.createdByUser.id, username: row.createdByUser.username }
: null,
updatedByUser: row.updatedByUser
? { id: row.updatedByUser.id, username: row.updatedByUser.username }
: null,
};
}
private rethrowUniqueViolation(error: unknown): never {
let current: unknown = error;
for (let i = 0; i < 5; i++) {
if (!current || typeof current !== 'object') {
break;
}
const obj = current as { code?: string; cause?: unknown };
if (obj.code === '23505') {
throw new ConflictException('Attendance already exists for this shift');
}
current = obj.cause;
}
throw error;
}
}
@@ -0,0 +1,294 @@
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import type { PaginationResponse } from '../../../common/http/response';
import {
pickRelation,
pickUserRelation,
DEFAULT_RELATION_FIELDS,
USER_RELATION_FIELDS,
toListPage,
} from '../../../common/http/response';
import { DateTime } from '../../../common/value-objects/date-time/date-time';
import { Status } from '../../../common/value-objects/status/status';
import { BranchesService } from '../../configuration/branches/branches.service';
import { EmployeesService } from '../../configuration/employees/employees.service';
import { CompanySettingsService } from '../settings/company-settings.service';
import {
isCheckInMethod,
verifyBranchCheckIn,
type CheckInPayload,
type CheckInVerificationOptions,
} from '../shared/check-in-verification';
import { FIELD_ATTENDANCE_PRIVILEGE_KEY } from '../shared/field-purpose';
import { TimelineActivitiesService } from '../timeline/timeline-activities.service';
import type { Attendance } from './attendance';
import type {
AttendanceCheckInDto,
AttendanceCheckOutDto,
AttendanceDto,
ListAttendancesQueryDto,
} from './dto/attendance.dto';
import { AttendancesRepository } from './attendances.repository';
@Injectable()
export class AttendancesService {
constructor(
private readonly attendancesRepository: AttendancesRepository,
private readonly branchesService: BranchesService,
private readonly employeesService: EmployeesService,
private readonly companySettingsService: CompanySettingsService,
private readonly timelineActivitiesService: TimelineActivitiesService,
private readonly config: ConfigService,
) {}
async list(
query: ListAttendancesQueryDto,
): Promise<PaginationResponse<AttendanceDto>> {
const page = toListPage(query);
const { data, total } = await this.attendancesRepository.list({
employeeId: query.employeeId,
branchId: query.branchId,
date: query.date,
status: query.status,
search: query.search,
orderBy: query.orderBy,
orderType: query.orderType,
limit: page.limit,
offset: page.offset,
});
return {
data: data.map((item) => this.toItem(item)),
total,
};
}
async findById(id: string): Promise<AttendanceDto> {
const attendance = await this.attendancesRepository.findById(id);
if (!attendance) {
throw new NotFoundException('Attendance not found');
}
return this.toItem(attendance);
}
async findCurrent(userId: string): Promise<AttendanceDto | null> {
const employee = await this.employeesService.requireByUserId(userId);
const attendance = await this.attendancesRepository.findOpenByEmployeeId(
employee.id,
);
return attendance ? this.toItem(attendance) : null;
}
async checkIn(
dto: AttendanceCheckInDto,
userId: string,
): Promise<AttendanceDto> {
const employee = await this.employeesService.requireByUserId(userId);
const open = await this.attendancesRepository.findOpenByEmployeeId(
employee.id,
);
if (open) {
throw new ConflictException('An attendance shift is already open');
}
const branch = await this.branchesService.findById(dto.branchId);
const radiusMeters =
await this.companySettingsService.requireCheckInRadiusMeters();
const payload = this.toPayload(dto);
const verified = verifyBranchCheckIn(
{
code: branch.code,
nfcId: branch.nfcId,
latitude: branch.latitude,
longitude: branch.longitude,
},
payload,
radiusMeters,
this.gpsVerificationOptions(),
);
const now = DateTime.fromUnixMs(Date.now());
const created = await this.attendancesRepository.create({
employeeId: employee.id,
branchId: branch.id,
date: now.startOfDay(),
checkInAt: now,
checkInMethod: verified.method,
checkInLatitude: verified.latitude,
checkInLongitude: verified.longitude,
checkInPhotoUrl: verified.photoUrl,
checkInDistanceMeters: verified.distanceMeters,
userId,
});
await this.timelineActivitiesService.recordIfLocated({
employeeId: employee.id,
type: 'branch_check_in',
sourceType: 'attendance',
sourceId: created.id,
latitude: verified.latitude,
longitude: verified.longitude,
recordedAt: now.value,
});
return this.toItem(created);
}
async checkOut(
id: string,
dto: AttendanceCheckOutDto,
userId: string,
): Promise<AttendanceDto> {
const employee = await this.employeesService.requireByUserId(userId);
const attendance = await this.attendancesRepository.findById(id);
if (!attendance) {
throw new NotFoundException('Attendance not found');
}
if (attendance.employeeId !== employee.id) {
throw new BadRequestException('Attendance does not belong to this user');
}
if (attendance.checkOutAt) {
throw new ConflictException('Attendance is already checked out');
}
const branch = await this.branchesService.findById(attendance.branchId);
const radiusMeters =
await this.companySettingsService.requireCheckInRadiusMeters();
const payload = this.toPayload(dto);
const verified = verifyBranchCheckIn(
{
code: branch.code,
nfcId: branch.nfcId,
latitude: branch.latitude,
longitude: branch.longitude,
},
payload,
radiusMeters,
this.gpsVerificationOptions(),
);
const updated = await this.attendancesRepository.checkOut(id, {
checkOutAt: DateTime.fromUnixMs(Date.now()),
checkOutMethod: verified.method,
checkOutLatitude: verified.latitude,
checkOutLongitude: verified.longitude,
checkOutPhotoUrl: verified.photoUrl,
checkOutDistanceMeters: verified.distanceMeters,
userId,
});
await this.timelineActivitiesService.recordIfLocated({
employeeId: employee.id,
type: 'branch_check_out',
sourceType: 'attendance',
sourceId: updated.id,
latitude: verified.latitude,
longitude: verified.longitude,
recordedAt: updated.checkOutAt?.value,
});
return this.toItem(updated);
}
async updateStatus(
id: string,
statusRaw: string,
userId: string,
): Promise<AttendanceDto> {
const status = Status.create(statusRaw);
const updated = await this.attendancesRepository.updateStatus(
id,
status,
userId,
);
return this.toItem(updated);
}
async bulkUpdateStatus(
ids: string[],
statusRaw: string,
userId: string,
): Promise<{ updated: number }> {
const status = Status.create(statusRaw);
const updated = await this.attendancesRepository.bulkUpdateStatus(
ids,
status,
userId,
);
return { updated };
}
async delete(id: string): Promise<void> {
await this.attendancesRepository.delete(id);
}
async bulkDelete(ids: string[]): Promise<{ deleted: number }> {
const deleted = await this.attendancesRepository.bulkDelete(ids);
return { deleted };
}
toItem(attendance: Attendance): AttendanceDto {
return {
id: attendance.id,
employee: pickRelation(attendance.employee, DEFAULT_RELATION_FIELDS)!,
branch: pickRelation(attendance.branch, DEFAULT_RELATION_FIELDS)!,
date: attendance.date.value,
checkInAt: attendance.checkInAt.value,
checkInMethod: attendance.checkInMethod,
checkInLatitude: attendance.checkInLatitude,
checkInLongitude: attendance.checkInLongitude,
checkInPhotoUrl: attendance.checkInPhotoUrl,
checkInDistanceMeters: attendance.checkInDistanceMeters,
checkOutAt: attendance.checkOutAt?.value ?? null,
checkOutMethod: attendance.checkOutMethod,
checkOutLatitude: attendance.checkOutLatitude,
checkOutLongitude: attendance.checkOutLongitude,
checkOutPhotoUrl: attendance.checkOutPhotoUrl,
checkOutDistanceMeters: attendance.checkOutDistanceMeters,
status: attendance.status.value,
createdAt: attendance.createdAt.value,
updatedAt: attendance.updatedAt.value,
createdBy: pickUserRelation(
attendance.createdByUser ?? {
id: attendance.createdBy,
username: '',
},
),
updatedBy: pickUserRelation(
attendance.updatedByUser ?? {
id: attendance.updatedBy,
username: '',
},
),
};
}
static privilegeKey(): string {
return FIELD_ATTENDANCE_PRIVILEGE_KEY;
}
private gpsVerificationOptions(): CheckInVerificationOptions {
return {
skipGpsValidation:
this.config.get<boolean>('SKIP_GPS_VALIDATION') === true,
};
}
private toPayload(
dto: AttendanceCheckInDto | AttendanceCheckOutDto,
): CheckInPayload {
if (!isCheckInMethod(dto.method)) {
throw new BadRequestException('Invalid check-in method');
}
return {
method: dto.method,
nfcId: dto.nfcId,
qrCode: dto.qrCode,
latitude: dto.latitude,
longitude: dto.longitude,
photoUrl: dto.photoUrl,
};
}
}
@@ -0,0 +1,216 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
ArrayNotEmpty,
IsArray,
IsIn,
IsLatitude,
IsLongitude,
IsNumber,
IsOptional,
IsString,
IsUrl,
IsUUID,
ValidateIf,
} from 'class-validator';
import { CORE_STATUSES } from '../../../../common/value-objects/status/status';
import { PaginationQueryDto } from '../../../../common/http/response';
import { CHECK_IN_METHODS } from '../../shared/check-in-verification';
export class AttendanceCheckInDto {
@ApiProperty({ format: 'uuid' })
@IsUUID('4')
branchId!: string;
@ApiProperty({ enum: CHECK_IN_METHODS })
@IsIn([...CHECK_IN_METHODS])
method!: string;
@ApiPropertyOptional()
@ValidateIf((dto: AttendanceCheckInDto) => dto.method === 'nfc')
@IsString()
nfcId?: string;
@ApiPropertyOptional()
@ValidateIf((dto: AttendanceCheckInDto) => dto.method === 'qr')
@IsString()
qrCode?: string;
@ApiProperty()
@IsLatitude()
latitude!: number;
@ApiProperty()
@IsLongitude()
longitude!: number;
@ApiPropertyOptional()
@IsOptional()
@IsUrl({ require_protocol: true, protocols: ['https'] })
photoUrl?: string;
}
export class AttendanceCheckOutDto {
@ApiProperty({ enum: CHECK_IN_METHODS })
@IsIn([...CHECK_IN_METHODS])
method!: string;
@ApiPropertyOptional()
@ValidateIf((dto: AttendanceCheckOutDto) => dto.method === 'nfc')
@IsString()
nfcId?: string;
@ApiPropertyOptional()
@ValidateIf((dto: AttendanceCheckOutDto) => dto.method === 'qr')
@IsString()
qrCode?: string;
@ApiProperty()
@IsLatitude()
latitude!: number;
@ApiProperty()
@IsLongitude()
longitude!: number;
@ApiPropertyOptional()
@IsOptional()
@IsUrl({ require_protocol: true, protocols: ['https'] })
photoUrl?: string;
}
export class UpdateAttendanceStatusDto {
@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 ListAttendancesQueryDto extends PaginationQueryDto {
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID('4')
employeeId?: string;
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID('4')
branchId?: string;
@ApiPropertyOptional({ description: 'Unix ms start of calendar day' })
@IsOptional()
@IsNumber()
date?: number;
@ApiPropertyOptional({ enum: CORE_STATUSES })
@IsOptional()
@IsIn([...CORE_STATUSES])
status?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
search?: string;
}
class RelationDto {
@ApiProperty({ format: 'uuid' })
id!: string;
@ApiProperty()
code!: string;
@ApiProperty()
name!: string;
}
class UserRelationDto {
@ApiProperty({ format: 'uuid' })
id!: string;
@ApiProperty()
username!: string;
}
export class AttendanceDto {
@ApiProperty({ format: 'uuid' })
id!: string;
@ApiProperty({ type: RelationDto })
employee!: RelationDto;
@ApiProperty({ type: RelationDto })
branch!: RelationDto;
@ApiProperty()
date!: number;
@ApiProperty()
checkInAt!: number;
@ApiProperty({ enum: CHECK_IN_METHODS })
checkInMethod!: string;
@ApiProperty()
checkInLatitude!: number;
@ApiProperty()
checkInLongitude!: number;
@ApiProperty({ nullable: true })
checkInPhotoUrl!: string | null;
@ApiProperty({ nullable: true })
checkInDistanceMeters!: number | null;
@ApiProperty({ nullable: true })
checkOutAt!: number | null;
@ApiProperty({ enum: CHECK_IN_METHODS, nullable: true })
checkOutMethod!: string | null;
@ApiProperty({ nullable: true })
checkOutLatitude!: number | null;
@ApiProperty({ nullable: true })
checkOutLongitude!: number | null;
@ApiProperty({ nullable: true })
checkOutPhotoUrl!: string | null;
@ApiProperty({ nullable: true })
checkOutDistanceMeters!: number | null;
@ApiProperty()
status!: string;
@ApiProperty()
createdAt!: number;
@ApiProperty()
updatedAt!: number;
@ApiProperty({ type: UserRelationDto })
createdBy!: UserRelationDto;
@ApiProperty({ type: UserRelationDto })
updatedBy!: UserRelationDto;
}
@@ -34,7 +34,10 @@ describe('CyclesService', () => {
const employeesService = { findById: jest.fn(), findByCode: jest.fn() };
const branchesService = { findById: jest.fn(), findByCode: jest.fn() };
const customersService = { findById: jest.fn(), findByCode: jest.fn() };
const privilegesService = { checkPermission: jest.fn() };
const privilegesService = {
checkPermission: jest.fn(),
checkAnyPermission: jest.fn(),
};
const now = DateTime.fromUnixMs(1_700_000_000_000);
const user: AuthUser = {
+5 -5
View File
@@ -26,7 +26,7 @@ import {
type WeekdaysInput,
} from '../shared/field-fields';
import {
fieldPrivilegeKey,
fieldPrivilegeKeys,
isFieldPurpose,
WEEKDAY_NAMES,
type FieldPurpose,
@@ -485,9 +485,9 @@ export class CyclesService {
}
const allowed: FieldPurpose[] = [];
for (const purpose of ['sales', 'logistics'] as const) {
const ok = await this.privilegesService.checkPermission(
const ok = await this.privilegesService.checkAnyPermission(
user.id,
fieldPrivilegeKey('cycle', purpose),
fieldPrivilegeKeys('cycle', purpose),
action,
);
if (ok) {
@@ -505,9 +505,9 @@ export class CyclesService {
if (user.isSuperadmin) {
return;
}
const ok = await this.privilegesService.checkPermission(
const ok = await this.privilegesService.checkAnyPermission(
user.id,
fieldPrivilegeKey('cycle', purpose),
fieldPrivilegeKeys('cycle', purpose),
action,
);
if (!ok) {
+26 -1
View File
@@ -5,6 +5,10 @@ import { EmployeesModule } from '../configuration/employees/employees.module';
import { PrivilegesModule } from '../privileges/privileges.module';
import { PackingSlipsModule } from '../sales/packing-slips/packing-slips.module';
import { SalesInvoicesModule } from '../sales/sales-invoices/sales-invoices.module';
import { AttendancesReadController } from './attendances/attendances-read.controller';
import { AttendancesWriteController } from './attendances/attendances-write.controller';
import { AttendancesRepository } from './attendances/attendances.repository';
import { AttendancesService } from './attendances/attendances.service';
import { CyclesReadController } from './cycles/cycles-read.controller';
import { CyclesWriteController } from './cycles/cycles-write.controller';
import { CyclesRepository } from './cycles/cycles.repository';
@@ -16,7 +20,12 @@ import { PlansService } from './plans/plans.service';
import { CompanySettingsController } from './settings/company-settings.controller';
import { CompanySettingsRepository } from './settings/company-settings.repository';
import { CompanySettingsService } from './settings/company-settings.service';
import { VisitsReadController } from './visits/visits-read.controller';
import { VisitsWriteController } from './visits/visits-write.controller';
import { VisitsRepository } from './visits/visits.repository';
import { VisitsService } from './visits/visits.service';
import { FieldPrivilegeGuard } from './shared/field-privilege.guard';
import { TimelineModule } from './timeline/timeline.module';
@Module({
imports: [
@@ -26,23 +35,39 @@ import { FieldPrivilegeGuard } from './shared/field-privilege.guard';
CustomersModule,
SalesInvoicesModule,
PackingSlipsModule,
TimelineModule,
],
controllers: [
CompanySettingsController,
AttendancesReadController,
AttendancesWriteController,
CyclesReadController,
CyclesWriteController,
PlansReadController,
PlansWriteController,
VisitsReadController,
VisitsWriteController,
],
providers: [
FieldPrivilegeGuard,
CompanySettingsRepository,
CompanySettingsService,
AttendancesRepository,
AttendancesService,
CyclesRepository,
CyclesService,
PlansRepository,
PlansService,
VisitsRepository,
VisitsService,
],
exports: [
CompanySettingsService,
AttendancesService,
CyclesService,
PlansService,
VisitsService,
TimelineModule,
],
exports: [CompanySettingsService, CyclesService, PlansService],
})
export class FieldModule {}
+56 -17
View File
@@ -79,9 +79,7 @@ export class PlansRepository {
.limit(filters.limit)
.offset(filters.offset);
return {
data: await this.hydrate(
rows.map((row) => this.toDomain(row, [], [], [])),
),
data: await this.loadChildrenForRows(this.db, rows),
total: Number(totalRows[0]?.total ?? 0),
};
}
@@ -340,20 +338,61 @@ export class PlansRepository {
executor: QueryExecutor,
row: PlanRow,
): Promise<Plan> {
const destinations = await executor
.select()
.from(planDestinations)
.where(eq(planDestinations.planId, row.id))
.orderBy(asc(planDestinations.sortOrder));
const invoices = await executor
.select()
.from(planInvoices)
.where(eq(planInvoices.planId, row.id));
const packingSlips = await executor
.select()
.from(planPackingSlips)
.where(eq(planPackingSlips.planId, row.id));
return this.hydrateOne(row, destinations, invoices, packingSlips);
const [plan] = await this.loadChildrenForRows(executor, [row]);
if (!plan) {
throw new NotFoundException('Plan not found');
}
return plan;
}
private async loadChildrenForRows(
executor: QueryExecutor,
rows: PlanRow[],
): Promise<Plan[]> {
if (rows.length === 0) {
return [];
}
const ids = rows.map((row) => row.id);
const [destinationRows, invoiceRows, packingSlipRows] = await Promise.all([
executor
.select()
.from(planDestinations)
.where(inArray(planDestinations.planId, ids))
.orderBy(asc(planDestinations.sortOrder)),
executor
.select()
.from(planInvoices)
.where(inArray(planInvoices.planId, ids)),
executor
.select()
.from(planPackingSlips)
.where(inArray(planPackingSlips.planId, ids)),
]);
const destinationsByPlan = this.groupByPlanId(destinationRows);
const invoicesByPlan = this.groupByPlanId(invoiceRows);
const packingByPlan = this.groupByPlanId(packingSlipRows);
return this.hydrate(
rows.map((row) =>
this.toDomain(
row,
destinationsByPlan.get(row.id) ?? [],
invoicesByPlan.get(row.id) ?? [],
packingByPlan.get(row.id) ?? [],
),
),
);
}
private groupByPlanId<T extends { planId: string }>(
rows: T[],
): Map<string, T[]> {
const ids = [...new Set(rows.map((row) => row.planId))];
return new Map(
ids.map((planId) => [
planId,
rows.filter((row) => row.planId === planId),
]),
);
}
private async replaceChildren(
+98 -6
View File
@@ -16,6 +16,37 @@ import type { Plan } from './plan';
import { PlansRepository } from './plans.repository';
import { PlansService } from './plans.service';
const MS_PER_DAY = 86_400_000;
function todayYmd(): string {
return DateTime.fromUnixMs(Math.trunc(Date.now()))
.startOfDay()
.format()
.slice(0, 10);
}
function nextWeekdayYmd(weekday: string): string {
const start = DateTime.fromUnixMs(Math.trunc(Date.now())).startOfDay();
for (let offset = 0; offset < 8; offset += 1) {
const day = DateTime.fromUnixMs(
start.value + offset * MS_PER_DAY,
).startOfDay();
if (day.weekdayName() === weekday) {
return day.format().slice(0, 10);
}
}
return todayYmd();
}
function addDaysYmd(date: string, days: number): string {
return DateTime.fromUnixMs(
DateTime.create(date).startOfDay().value + days * MS_PER_DAY,
)
.startOfDay()
.format()
.slice(0, 10);
}
describe('PlansService', () => {
let service: PlansService;
let plansRepository: jest.Mocked<
@@ -41,7 +72,10 @@ describe('PlansService', () => {
markDraftsProcessed: jest.fn(),
};
const packingSlipsService = { findById: jest.fn() };
const privilegesService = { checkPermission: jest.fn() };
const privilegesService = {
checkPermission: jest.fn(),
checkAnyPermission: jest.fn(),
};
const user: AuthUser = {
id: 'user-1',
@@ -170,7 +204,7 @@ describe('PlansService', () => {
service.create({
employeeId: 'emp-1',
purpose: 'sales',
date: '2026-01-05',
date: todayYmd(),
startBranchId: 'br-1',
endBranchId: 'br-2',
customerIds: ['cus-1'],
@@ -180,15 +214,73 @@ describe('PlansService', () => {
).rejects.toBeInstanceOf(BadRequestException);
});
it('rejects a plan date in the past', async () => {
await expect(
service.create({
employeeId: 'emp-1',
purpose: 'sales',
date: '2020-01-01',
startBranchId: 'br-1',
endBranchId: 'br-2',
customerIds: ['cus-1'],
userId: 'user-1',
}),
).rejects.toMatchObject({ message: 'Date cannot be in the past' });
});
it('rejects invoices that do not belong to selected customers', async () => {
salesInvoicesService.findById.mockResolvedValue({
id: 'inv-1',
customer: { id: 'cus-2', code: 'C2', name: 'Beta' },
});
await expect(
service.create({
employeeId: 'emp-1',
purpose: 'sales',
date: todayYmd(),
startBranchId: 'br-1',
endBranchId: 'br-2',
customerIds: ['cus-1'],
invoiceIds: ['inv-1'],
userId: 'user-1',
}),
).rejects.toMatchObject({
message: 'Invoice does not belong to a selected customer',
});
});
it('keeps an existing past date when the date is unchanged', async () => {
plansRepository.findById.mockResolvedValue(plan);
plansRepository.update.mockResolvedValue(plan);
await service.update(
'pln-1',
{ date: '2026-01-05', userId: 'user-1' },
user,
);
expect(plansRepository.update).toHaveBeenCalled();
});
it('rejects changing a plan date into the past', async () => {
plansRepository.findById.mockResolvedValue({
...plan,
date: DateTime.create(todayYmd()),
});
await expect(
service.update('pln-1', { date: '2020-01-01', userId: 'user-1' }, user),
).rejects.toMatchObject({ message: 'Date cannot be in the past' });
});
it('generate copies a weekday and skips an existing plan', async () => {
const from = nextWeekdayYmd('monday');
const to = addDaysYmd(from, 7);
plansRepository.findLiveByKey
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(plan);
const result = await service.generate({
employeeId: 'emp-1',
purpose: 'sales',
from: '2026-01-05',
to: '2026-01-12',
from,
to,
userId: 'user-1',
});
expect(result.created).toBe(1);
@@ -205,8 +297,8 @@ describe('PlansService', () => {
service.generate({
employeeId: 'emp-1',
purpose: 'sales',
from: '2026-01-05',
to: '2026-01-05',
from: todayYmd(),
to: todayYmd(),
userId: 'user-1',
}),
).rejects.toBeInstanceOf(BadRequestException);
+49 -17
View File
@@ -27,7 +27,7 @@ import { CyclesRepository } from '../cycles/cycles.repository';
import { CompanySettingsService } from '../settings/company-settings.service';
import {
cycleNumberForDate,
fieldPrivilegeKey,
fieldPrivilegeKeys,
isFieldPurpose,
noCycleMessage,
type FieldPurpose,
@@ -173,6 +173,9 @@ export class PlansService {
const date = input.date
? this.assertDate(input.date).startOfDay()
: existing.date;
if (input.date && date.value !== existing.date.value) {
this.assertNotInThePast(date);
}
await this.employeesService.findById(employeeId);
await this.assertUnique(employeeId, purpose, date.value, id);
const startBranchId = input.startBranchId ?? existing.startBranchId;
@@ -189,12 +192,8 @@ export class PlansService {
input.invoiceIds ?? [...existing.invoiceIds],
input.packingSlipIds ?? [...existing.packingSlipIds],
);
if (input.invoiceIds) {
await this.assertInvoices(input.invoiceIds);
}
if (input.packingSlipIds) {
await this.assertPackingSlips(input.packingSlipIds);
}
await this.assertInvoices(attachments.invoiceIds, customerIds);
await this.assertPackingSlips(attachments.packingSlipIds, customerIds);
const updated = await this.plansRepository.update(id, {
employeeId,
purpose,
@@ -278,6 +277,8 @@ export class PlansService {
if (to.value < from.value) {
throw new BadRequestException('Invalid date range');
}
this.assertNotInThePast(from);
this.assertNotInThePast(to);
if (from.value < epoch.startOfDay().value) {
throw new BadRequestException('Date is before the cycle start date');
}
@@ -446,6 +447,7 @@ export class PlansService {
}) {
const purpose = this.assertPurpose(input.purpose);
const date = this.assertDate(input.date).startOfDay();
this.assertNotInThePast(date);
await this.employeesService.findById(input.employeeId);
await this.assertUnique(input.employeeId, purpose, date.value);
const geometry = await this.buildGeometry(
@@ -458,8 +460,11 @@ export class PlansService {
input.invoiceIds ?? [],
input.packingSlipIds ?? [],
);
await this.assertInvoices(attachments.invoiceIds);
await this.assertPackingSlips(attachments.packingSlipIds);
await this.assertInvoices(attachments.invoiceIds, input.customerIds);
await this.assertPackingSlips(
attachments.packingSlipIds,
input.customerIds,
);
return {
employeeId: input.employeeId,
purpose,
@@ -544,15 +549,35 @@ export class PlansService {
await this.salesInvoicesService.markDraftsProcessed(newlyAttached, userId);
}
private async assertInvoices(ids: readonly string[]): Promise<void> {
private async assertInvoices(
ids: readonly string[],
customerIds: readonly string[],
): Promise<void> {
const allowed = new Set(customerIds);
for (const id of ids) {
await this.salesInvoicesService.findById(id);
const invoice = await this.salesInvoicesService.findById(id);
const customerId = invoice.customer?.id;
if (!customerId || !allowed.has(customerId)) {
throw new BadRequestException(
'Invoice does not belong to a selected customer',
);
}
}
}
private async assertPackingSlips(ids: readonly string[]): Promise<void> {
private async assertPackingSlips(
ids: readonly string[],
customerIds: readonly string[],
): Promise<void> {
const allowed = new Set(customerIds);
for (const id of ids) {
await this.packingSlipsService.findById(id);
const packingSlip = await this.packingSlipsService.findById(id);
const customerId = packingSlip.customer?.id;
if (!customerId || !allowed.has(customerId)) {
throw new BadRequestException(
'Packing slip does not belong to a selected customer',
);
}
}
}
@@ -590,9 +615,9 @@ export class PlansService {
}
const allowed: FieldPurpose[] = [];
for (const purpose of ['sales', 'logistics'] as const) {
const ok = await this.privilegesService.checkPermission(
const ok = await this.privilegesService.checkAnyPermission(
user.id,
fieldPrivilegeKey('plan', purpose),
fieldPrivilegeKeys('plan', purpose),
action,
);
if (ok) {
@@ -610,9 +635,9 @@ export class PlansService {
if (user.isSuperadmin) {
return;
}
const ok = await this.privilegesService.checkPermission(
const ok = await this.privilegesService.checkAnyPermission(
user.id,
fieldPrivilegeKey('plan', purpose),
fieldPrivilegeKeys('plan', purpose),
action,
);
if (!ok) {
@@ -649,6 +674,13 @@ export class PlansService {
}
}
private assertNotInThePast(date: DateTime): void {
const today = DateTime.fromUnixMs(Math.trunc(Date.now())).startOfDay();
if (date.startOfDay().value < today.value) {
throw new BadRequestException('Date cannot be in the past');
}
}
private assertStatus(raw: string): Status {
try {
return Status.create(raw);
@@ -4,6 +4,9 @@ import { Status } from '../../../common/value-objects/status/status';
export type CompanySetting = {
readonly id: string;
readonly cycleStartDate: DateTime;
readonly checkInRadiusMeters: number;
readonly gpsIntervalSeconds: number;
readonly checkoutWarningRadiusMeters: number;
readonly status: Status;
readonly createdAt: DateTime;
readonly updatedAt: DateTime;
@@ -13,5 +16,8 @@ export type CompanySetting = {
export type UpsertCompanySettingInput = {
readonly cycleStartDate: DateTime;
readonly checkInRadiusMeters?: number;
readonly gpsIntervalSeconds?: number;
readonly checkoutWarningRadiusMeters?: number;
readonly userId: string;
};
@@ -47,6 +47,6 @@ export class CompanySettingsController {
@Body() dto: UpdateCompanySettingDto,
@CurrentUser('id') userId: string,
): Promise<CompanySettingDto> {
return this.companySettingsService.update(dto.cycleStartDate, userId);
return this.companySettingsService.update(dto, userId);
}
}
@@ -33,6 +33,9 @@ export class CompanySettingsRepository {
.insert(companySettings)
.values({
cycleStartDate: input.cycleStartDate.value,
checkInRadiusMeters: input.checkInRadiusMeters ?? 100,
gpsIntervalSeconds: input.gpsIntervalSeconds ?? 5,
checkoutWarningRadiusMeters: input.checkoutWarningRadiusMeters ?? 200,
status: Status.create(Status.DEFAULT).value,
createdAt: now.value,
updatedAt: now.value,
@@ -46,6 +49,15 @@ export class CompanySettingsRepository {
.update(companySettings)
.set({
cycleStartDate: input.cycleStartDate.value,
...(input.checkInRadiusMeters !== undefined
? { checkInRadiusMeters: input.checkInRadiusMeters }
: {}),
...(input.gpsIntervalSeconds !== undefined
? { gpsIntervalSeconds: input.gpsIntervalSeconds }
: {}),
...(input.checkoutWarningRadiusMeters !== undefined
? { checkoutWarningRadiusMeters: input.checkoutWarningRadiusMeters }
: {}),
updatedAt: now.value,
updatedBy: input.userId,
})
@@ -58,6 +70,9 @@ export class CompanySettingsRepository {
return {
id: row.id,
cycleStartDate: DateTime.fromUnixMs(row.cycleStartDate),
checkInRadiusMeters: row.checkInRadiusMeters,
gpsIntervalSeconds: row.gpsIntervalSeconds,
checkoutWarningRadiusMeters: row.checkoutWarningRadiusMeters,
status: Status.create(row.status),
createdAt: DateTime.fromUnixMs(row.createdAt),
updatedAt: DateTime.fromUnixMs(row.updatedAt),
@@ -16,6 +16,9 @@ describe('CompanySettingsService', () => {
const sample: CompanySetting = {
id: 'set-1',
cycleStartDate: DateTime.create('2026-01-05'),
checkInRadiusMeters: 100,
gpsIntervalSeconds: 5,
checkoutWarningRadiusMeters: 200,
status: Status.create('draft'),
createdAt: now,
updatedAt: now,
@@ -46,19 +49,46 @@ describe('CompanySettingsService', () => {
repository.find.mockResolvedValue(sample);
const result = await service.get();
expect(result.cycleStartDate).toBe(sample.cycleStartDate.value);
expect(result.checkInRadiusMeters).toBe(100);
expect(result.gpsIntervalSeconds).toBe(5);
expect(result.checkoutWarningRadiusMeters).toBe(200);
});
it('update persists start of day', async () => {
repository.find.mockResolvedValue(sample);
repository.upsert.mockResolvedValue(sample);
await service.update('2026-01-05', 'user-1');
await service.update({ cycleStartDate: '2026-01-05' }, 'user-1');
const arg = repository.upsert.mock.calls[0][0];
expect(arg.cycleStartDate.equals(DateTime.create('2026-01-05'))).toBe(true);
expect(arg.userId).toBe('user-1');
});
it('update rejects invalid dates', async () => {
await expect(service.update('not-a-date', 'user-1')).rejects.toBeInstanceOf(
BadRequestException,
it('update rejects invalid check-in radius', async () => {
repository.find.mockResolvedValue(sample);
await expect(
service.update({ checkInRadiusMeters: 0 }, 'user-1'),
).rejects.toBeInstanceOf(BadRequestException);
});
it('update rejects invalid GPS interval', async () => {
repository.find.mockResolvedValue(sample);
await expect(
service.update({ gpsIntervalSeconds: 2 }, 'user-1'),
).rejects.toBeInstanceOf(BadRequestException);
});
it('requireTimelineConfig returns tracking settings', async () => {
repository.find.mockResolvedValue(sample);
await expect(service.requireTimelineConfig()).resolves.toEqual({
gpsIntervalSeconds: 5,
checkoutWarningRadiusMeters: 200,
});
});
it('update rejects when settings are missing and no cycle date provided', async () => {
repository.find.mockResolvedValue(null);
await expect(service.update({}, 'user-1')).rejects.toBeInstanceOf(
NotFoundException,
);
});
});
@@ -23,17 +23,58 @@ export class CompanySettingsService {
}
async update(
cycleStartDateRaw: string,
input: {
cycleStartDate?: string;
checkInRadiusMeters?: number;
gpsIntervalSeconds?: number;
checkoutWarningRadiusMeters?: number;
},
userId: string,
): Promise<ReturnType<CompanySettingsService['toItem']>> {
const cycleStartDate = this.assertDate(cycleStartDateRaw).startOfDay();
const existing = await this.companySettingsRepository.find();
const cycleStartDate = input.cycleStartDate
? this.assertDate(input.cycleStartDate).startOfDay()
: existing?.cycleStartDate;
if (!cycleStartDate) {
throw new NotFoundException('Settings not configured');
}
if (
input.checkInRadiusMeters !== undefined &&
(input.checkInRadiusMeters < 1 || input.checkInRadiusMeters > 10_000)
) {
throw new BadRequestException('Invalid check-in radius');
}
if (
input.gpsIntervalSeconds !== undefined &&
(input.gpsIntervalSeconds < 5 || input.gpsIntervalSeconds > 300)
) {
throw new BadRequestException('Invalid GPS interval');
}
if (
input.checkoutWarningRadiusMeters !== undefined &&
(input.checkoutWarningRadiusMeters < 1 ||
input.checkoutWarningRadiusMeters > 10_000)
) {
throw new BadRequestException('Invalid checkout warning radius');
}
const saved = await this.companySettingsRepository.upsert({
cycleStartDate,
checkInRadiusMeters: input.checkInRadiusMeters,
gpsIntervalSeconds: input.gpsIntervalSeconds,
checkoutWarningRadiusMeters: input.checkoutWarningRadiusMeters,
userId,
});
return this.toItem(saved);
}
async requireCheckInRadiusMeters(): Promise<number> {
const setting = await this.companySettingsRepository.find();
if (!setting) {
throw new NotFoundException('Settings not configured');
}
return setting.checkInRadiusMeters;
}
async requireCycleStartDate(): Promise<DateTime> {
const setting = await this.companySettingsRepository.find();
if (!setting) {
@@ -42,10 +83,27 @@ export class CompanySettingsService {
return setting.cycleStartDate;
}
async requireTimelineConfig(): Promise<{
gpsIntervalSeconds: number;
checkoutWarningRadiusMeters: number;
}> {
const setting = await this.companySettingsRepository.find();
if (!setting) {
throw new NotFoundException('Settings not configured');
}
return {
gpsIntervalSeconds: setting.gpsIntervalSeconds,
checkoutWarningRadiusMeters: setting.checkoutWarningRadiusMeters,
};
}
toItem(setting: CompanySetting) {
return {
id: setting.id,
cycleStartDate: setting.cycleStartDate.value,
checkInRadiusMeters: setting.checkInRadiusMeters,
gpsIntervalSeconds: setting.gpsIntervalSeconds,
checkoutWarningRadiusMeters: setting.checkoutWarningRadiusMeters,
status: setting.status.value,
createdAt: setting.createdAt.value,
updatedAt: setting.updatedAt.value,
@@ -1,14 +1,44 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString, Matches } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
IsInt,
IsNotEmpty,
IsOptional,
IsString,
Matches,
Max,
Min,
} from 'class-validator';
export class UpdateCompanySettingDto {
@ApiProperty({ example: '2026-01-05', description: 'YYYY-MM-DD' })
@ApiPropertyOptional({ example: '2026-01-05', description: 'YYYY-MM-DD' })
@IsOptional()
@IsString()
@IsNotEmpty()
@Matches(/^\d{4}-\d{2}-\d{2}$/, {
message: 'cycleStartDate must be a calendar date',
})
cycleStartDate!: string;
cycleStartDate?: string;
@ApiPropertyOptional({ example: 100, minimum: 1, maximum: 10000 })
@IsOptional()
@IsInt()
@Min(1)
@Max(10_000)
checkInRadiusMeters?: number;
@ApiPropertyOptional({ example: 5, minimum: 5, maximum: 300 })
@IsOptional()
@IsInt()
@Min(5)
@Max(300)
gpsIntervalSeconds?: number;
@ApiPropertyOptional({ example: 200, minimum: 1, maximum: 10000 })
@IsOptional()
@IsInt()
@Min(1)
@Max(10_000)
checkoutWarningRadiusMeters?: number;
}
export class CompanySettingDto {
@@ -18,6 +48,15 @@ export class CompanySettingDto {
@ApiProperty({ description: 'Unix ms start of the cycle-start calendar day' })
cycleStartDate!: number;
@ApiProperty({ example: 100 })
checkInRadiusMeters!: number;
@ApiProperty({ example: 5 })
gpsIntervalSeconds!: number;
@ApiProperty({ example: 200 })
checkoutWarningRadiusMeters!: number;
@ApiProperty()
status!: string;
@@ -0,0 +1,111 @@
import {
verifyBranchCheckIn,
verifyCustomerCheckIn,
} from './check-in-verification';
import { haversineDistanceMeters } from './geo-distance';
describe('geo-distance', () => {
it('returns zero for identical coordinates', () => {
expect(haversineDistanceMeters(-6.2, 106.8, -6.2, 106.8)).toBe(0);
});
});
describe('check-in-verification', () => {
const target = {
code: 'BR001',
nfcId: 'nfc-123',
latitude: -6.2,
longitude: 106.8,
};
it('accepts matching NFC tag', () => {
const result = verifyBranchCheckIn(
target,
{
method: 'nfc',
nfcId: 'nfc-123',
latitude: -6.2,
longitude: 106.8,
},
100,
);
expect(result.method).toBe('nfc');
});
it('accepts matching QR code', () => {
const result = verifyBranchCheckIn(
target,
{
method: 'qr',
qrCode: 'BR001',
latitude: -6.2,
longitude: 106.8,
},
100,
);
expect(result.method).toBe('qr');
});
it('rejects GPS when too far', () => {
expect(() =>
verifyCustomerCheckIn(
target,
{
method: 'gps',
latitude: -7,
longitude: 107.5,
},
100,
),
).toThrow('Too far from the customer location');
});
it('accepts GPS when too far if skipGpsValidation is set', () => {
const result = verifyBranchCheckIn(
target,
{
method: 'gps',
latitude: -7,
longitude: 107.5,
},
100,
{ skipGpsValidation: true },
);
expect(result.method).toBe('gps');
expect(result.distanceMeters).toBeGreaterThan(100);
expect(Number.isInteger(result.distanceMeters)).toBe(true);
});
it('returns whole meters for NFC distance so it matches the integer column', () => {
const result = verifyBranchCheckIn(
target,
{
method: 'nfc',
nfcId: 'nfc-123',
latitude: -6.2005,
longitude: 106.8005,
},
100,
);
expect(result.distanceMeters).not.toBeNull();
expect(Number.isInteger(result.distanceMeters)).toBe(true);
});
it('accepts GPS when the target has no location if skipGpsValidation is set', () => {
const result = verifyCustomerCheckIn(
{ ...target, latitude: null, longitude: null },
{
method: 'gps',
latitude: -7,
longitude: 107.5,
},
100,
{ skipGpsValidation: true },
);
expect(result.method).toBe('gps');
expect(result.distanceMeters).toBeNull();
});
});
@@ -0,0 +1,158 @@
import { BadRequestException } from '@nestjs/common';
import { haversineDistanceMeters } from './geo-distance';
function roundedDistanceMeters(
lat1: number,
lon1: number,
lat2: number,
lon2: number,
): number {
return Math.round(haversineDistanceMeters(lat1, lon1, lat2, lon2));
}
export const CHECK_IN_METHODS = ['nfc', 'qr', 'gps'] as const;
export type CheckInMethod = (typeof CHECK_IN_METHODS)[number];
export function isCheckInMethod(raw: string): raw is CheckInMethod {
return (CHECK_IN_METHODS as readonly string[]).includes(raw);
}
export type CheckInPayload = {
readonly method: CheckInMethod;
readonly nfcId?: string;
readonly qrCode?: string;
readonly latitude: number;
readonly longitude: number;
readonly photoUrl?: string;
};
export type CheckInVerificationResult = {
readonly latitude: number;
readonly longitude: number;
readonly distanceMeters: number | null;
readonly method: CheckInMethod;
readonly photoUrl: string | null;
};
export type CheckInVerificationOptions = {
readonly skipGpsValidation?: boolean;
};
export type BranchCheckInTarget = {
readonly code: string;
readonly nfcId: string | null;
readonly latitude: number | null;
readonly longitude: number | null;
};
export type CustomerCheckInTarget = {
readonly code: string;
readonly nfcId: string | null;
readonly latitude: number | null;
readonly longitude: number | null;
};
export function verifyBranchCheckIn(
target: BranchCheckInTarget,
payload: CheckInPayload,
radiusMeters: number,
options?: CheckInVerificationOptions,
): CheckInVerificationResult {
return verifyTargetCheckIn(
target,
payload,
radiusMeters,
'NFC tag does not match this branch',
'QR code does not match this branch',
'Branch location is not configured',
'Too far from the branch location',
options,
);
}
export function verifyCustomerCheckIn(
target: CustomerCheckInTarget,
payload: CheckInPayload,
radiusMeters: number,
options?: CheckInVerificationOptions,
): CheckInVerificationResult {
return verifyTargetCheckIn(
target,
payload,
radiusMeters,
'NFC tag does not match this customer',
'QR code does not match this customer',
'Customer location is not configured',
'Too far from the customer location',
options,
);
}
function verifyTargetCheckIn(
target: BranchCheckInTarget | CustomerCheckInTarget,
payload: CheckInPayload,
radiusMeters: number,
nfcMismatchMessage: string,
qrMismatchMessage: string,
locationMissingMessage: string,
tooFarMessage: string,
options?: CheckInVerificationOptions,
): CheckInVerificationResult {
if (!isCheckInMethod(payload.method)) {
throw new BadRequestException('Invalid check-in method');
}
let distanceMeters: number | null = null;
if (payload.method === 'nfc') {
if (!payload.nfcId?.trim()) {
throw new BadRequestException('NFC tag is required');
}
if (!target.nfcId || payload.nfcId.trim() !== target.nfcId) {
throw new BadRequestException(nfcMismatchMessage);
}
} else if (payload.method === 'qr') {
if (!payload.qrCode?.trim()) {
throw new BadRequestException('QR code is required');
}
if (payload.qrCode.trim() !== target.code) {
throw new BadRequestException(qrMismatchMessage);
}
} else {
const targetLatitude = target.latitude;
const targetLongitude = target.longitude;
const canMeasure = targetLatitude != null && targetLongitude != null;
if (!canMeasure && !options?.skipGpsValidation) {
throw new BadRequestException(locationMissingMessage);
}
if (canMeasure) {
distanceMeters = roundedDistanceMeters(
payload.latitude,
payload.longitude,
targetLatitude,
targetLongitude,
);
if (!options?.skipGpsValidation && distanceMeters > radiusMeters) {
throw new BadRequestException(tooFarMessage);
}
}
}
return {
latitude: payload.latitude,
longitude: payload.longitude,
distanceMeters:
distanceMeters == null
? target.latitude != null && target.longitude != null
? roundedDistanceMeters(
payload.latitude,
payload.longitude,
target.latitude,
target.longitude,
)
: null
: distanceMeters,
method: payload.method,
photoUrl: payload.photoUrl?.trim() ? payload.photoUrl.trim() : null,
};
}
@@ -10,14 +10,14 @@ import { REQUIRE_FIELD_PRIVILEGE_KEY } from './field-privilege.decorator';
import { FieldPrivilegeGuard } from './field-privilege.guard';
describe('FieldPrivilegeGuard', () => {
const checkPermission = jest.fn();
const checkAnyPermission = jest.fn();
const getAllAndOverride = jest.fn();
const reflector = {
getAllAndOverride,
} as unknown as Reflector;
const guard = new FieldPrivilegeGuard(reflector, {
checkPermission,
checkAnyPermission,
} as never);
const user: AuthUser = {
@@ -48,7 +48,7 @@ describe('FieldPrivilegeGuard', () => {
it('allows when no field privilege metadata', async () => {
getAllAndOverride.mockReturnValue(undefined);
await expect(guard.canActivate(createContext(user))).resolves.toBe(true);
expect(checkPermission).not.toHaveBeenCalled();
expect(checkAnyPermission).not.toHaveBeenCalled();
});
it('allows sales purpose when sales key is granted', async () => {
@@ -57,8 +57,9 @@ describe('FieldPrivilegeGuard', () => {
action: 'create',
};
getAllAndOverride.mockReturnValue(meta);
checkPermission.mockImplementation((_id: string, key: string) =>
Promise.resolve(key === 'SALES.CYCLE'),
checkAnyPermission.mockImplementation(
(_id: string, keys: readonly string[]) =>
Promise.resolve(keys.includes('ADMIN.SALES.DATA.CYCLE')),
);
await expect(
@@ -72,8 +73,9 @@ describe('FieldPrivilegeGuard', () => {
action: 'update',
};
getAllAndOverride.mockReturnValue(meta);
checkPermission.mockImplementation((_id: string, key: string) =>
Promise.resolve(key === 'SALES.PLAN'),
checkAnyPermission.mockImplementation(
(_id: string, keys: readonly string[]) =>
Promise.resolve(keys.includes('MOBILE.SALES.PLAN')),
);
await expect(
@@ -87,8 +89,9 @@ describe('FieldPrivilegeGuard', () => {
action: 'view',
};
getAllAndOverride.mockReturnValue(meta);
checkPermission.mockImplementation((_id: string, key: string) =>
Promise.resolve(key === 'LOGISTICS.CYCLE'),
checkAnyPermission.mockImplementation(
(_id: string, keys: readonly string[]) =>
Promise.resolve(keys.includes('ADMIN.LOGISTICS.DATA.CYCLE')),
);
await expect(guard.canActivate(createContext(user, {}, {}))).resolves.toBe(
@@ -106,7 +109,7 @@ describe('FieldPrivilegeGuard', () => {
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 () => {
@@ -10,7 +10,7 @@ import type { AuthUser } from '../../../common/auth/auth-user';
import type { PrivilegeAction } from '../../privileges/privilege-action';
import { PrivilegesService } from '../../privileges/privileges.service';
import {
fieldPrivilegeKey,
fieldPrivilegeKeys,
isFieldPurpose,
type FieldPurpose,
type FieldResource,
@@ -78,9 +78,9 @@ export class FieldPrivilegeGuard implements CanActivate {
const purposes: FieldPurpose[] = ['sales', 'logistics'];
const matches: FieldPurpose[] = [];
for (const purpose of purposes) {
const ok = await this.privilegesService.checkPermission(
const ok = await this.privilegesService.checkAnyPermission(
userId,
fieldPrivilegeKey(resource, purpose),
fieldPrivilegeKeys(resource, purpose),
action,
);
if (ok) {
@@ -1,6 +1,6 @@
import {
cycleNumberForDate,
fieldPrivilegeKey,
fieldPrivilegeKeys,
isFieldPurpose,
isWeekdayName,
noCycleMessage,
@@ -19,8 +19,13 @@ describe('field purpose helpers', () => {
});
it('maps resource and purpose to privilege keys', () => {
expect(fieldPrivilegeKey('cycle', 'sales')).toBe('SALES.CYCLE');
expect(fieldPrivilegeKey('plan', 'logistics')).toBe('LOGISTICS.PLAN');
expect(fieldPrivilegeKeys('cycle', 'sales')).toEqual([
'ADMIN.SALES.DATA.CYCLE',
]);
expect(fieldPrivilegeKeys('plan', 'logistics')).toEqual([
'ADMIN.LOGISTICS.ACTIVITIES.PLAN',
'MOBILE.LOGISTICS.PLAN',
]);
});
it('returns purpose-specific missing-cycle messages', () => {
+19 -11
View File
@@ -22,26 +22,34 @@ export function isWeekdayName(raw: string): raw is WeekdayName {
return (WEEKDAY_NAMES as readonly string[]).includes(raw);
}
export const SALES_CYCLE_PRIVILEGE_KEY = 'SALES.CYCLE';
export const SALES_PLAN_PRIVILEGE_KEY = 'SALES.PLAN';
export const LOGISTICS_CYCLE_PRIVILEGE_KEY = 'LOGISTICS.CYCLE';
export const LOGISTICS_PLAN_PRIVILEGE_KEY = 'LOGISTICS.PLAN';
export const SETTINGS_PRIVILEGE_KEY = 'CONFIGURATION.SETTING';
export const ADMIN_SALES_CYCLE_PRIVILEGE_KEY = 'ADMIN.SALES.DATA.CYCLE';
export const ADMIN_SALES_PLAN_PRIVILEGE_KEY = 'ADMIN.SALES.ACTIVITIES.PLAN';
export const ADMIN_LOGISTICS_CYCLE_PRIVILEGE_KEY = 'ADMIN.LOGISTICS.DATA.CYCLE';
export const ADMIN_LOGISTICS_PLAN_PRIVILEGE_KEY =
'ADMIN.LOGISTICS.ACTIVITIES.PLAN';
export const MOBILE_SALES_PLAN_PRIVILEGE_KEY = 'MOBILE.SALES.PLAN';
export const MOBILE_LOGISTICS_PLAN_PRIVILEGE_KEY = 'MOBILE.LOGISTICS.PLAN';
export const SETTINGS_PRIVILEGE_KEY = 'ADMIN.SETTINGS.DATA.SETTING';
export const ADMIN_SALES_TIMELINE_PRIVILEGE_KEY =
'ADMIN.SALES.ACTIVITIES.TIMELINE';
export const MOBILE_SALES_TIMELINE_PRIVILEGE_KEY = 'MOBILE.SALES.TIMELINE';
export const FIELD_ATTENDANCE_PRIVILEGE_KEY = 'MOBILE.SALES.PLAN.ATTENDANCE';
export const FIELD_VISIT_PRIVILEGE_KEY = 'MOBILE.SALES.VISIT';
export type FieldResource = 'cycle' | 'plan';
export function fieldPrivilegeKey(
export function fieldPrivilegeKeys(
resource: FieldResource,
purpose: FieldPurpose,
): string {
): readonly string[] {
if (resource === 'cycle') {
return purpose === 'sales'
? SALES_CYCLE_PRIVILEGE_KEY
: LOGISTICS_CYCLE_PRIVILEGE_KEY;
? [ADMIN_SALES_CYCLE_PRIVILEGE_KEY]
: [ADMIN_LOGISTICS_CYCLE_PRIVILEGE_KEY];
}
return purpose === 'sales'
? SALES_PLAN_PRIVILEGE_KEY
: LOGISTICS_PLAN_PRIVILEGE_KEY;
? [ADMIN_SALES_PLAN_PRIVILEGE_KEY, MOBILE_SALES_PLAN_PRIVILEGE_KEY]
: [ADMIN_LOGISTICS_PLAN_PRIVILEGE_KEY, MOBILE_LOGISTICS_PLAN_PRIVILEGE_KEY];
}
export function noCycleMessage(purpose: FieldPurpose): string {
+21
View File
@@ -0,0 +1,21 @@
/**
* Haversine distance between two WGS-84 coordinates in meters.
*/
export function haversineDistanceMeters(
lat1: number,
lon1: number,
lat2: number,
lon2: number,
): number {
const earthRadiusMeters = 6_371_000;
const toRadians = (degrees: number) => (degrees * Math.PI) / 180;
const dLat = toRadians(lat2 - lat1);
const dLon = toRadians(lon2 - lon1);
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos(toRadians(lat1)) *
Math.cos(toRadians(lat2)) *
Math.sin(dLon / 2) ** 2;
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return earthRadiusMeters * c;
}
@@ -0,0 +1,149 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
ArrayMaxSize,
ArrayNotEmpty,
IsArray,
IsIn,
IsInt,
IsNotEmpty,
IsNumber,
IsOptional,
IsString,
IsUUID,
Matches,
Max,
Min,
ValidateNested,
} from 'class-validator';
import {
DefaultRelationDto,
PaginationMetaDto,
} from '../../../../common/http/response';
import { TIMELINE_ACTIVITY_TYPES } from '../../../../database/timeline-activities-table';
export class TimelineConfigDto {
@ApiProperty({ example: 5 })
gpsIntervalSeconds!: number;
@ApiProperty({ example: 200 })
checkoutWarningRadiusMeters!: number;
}
export class TimelineFootprintPointDto {
@ApiProperty({ example: -6.2 })
@IsNumber()
@Min(-90)
@Max(90)
latitude!: number;
@ApiProperty({ example: 106.8 })
@IsNumber()
@Min(-180)
@Max(180)
longitude!: number;
@ApiProperty({ description: 'Unix ms when the point was recorded' })
@IsInt()
@Min(1)
recordedAt!: number;
}
export class IngestTimelineFootprintsDto {
@ApiProperty({ type: [TimelineFootprintPointDto] })
@IsArray()
@ArrayNotEmpty()
@ArrayMaxSize(100)
@ValidateNested({ each: true })
@Type(() => TimelineFootprintPointDto)
points!: TimelineFootprintPointDto[];
}
export class IngestTimelineFootprintsResultDto {
@ApiProperty()
inserted!: number;
}
export class ListTimelineQueryDto {
@ApiPropertyOptional({ example: '2026-09-01', description: 'YYYY-MM-DD' })
@IsOptional()
@IsString()
@IsNotEmpty()
@Matches(/^\d{4}-\d{2}-\d{2}$/, { message: 'date must be YYYY-MM-DD' })
date?: string;
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID('4')
employeeId?: string;
}
export class TimelineFootprintDto {
@ApiProperty({ format: 'uuid' })
id!: string;
@ApiProperty({ type: DefaultRelationDto })
employee!: DefaultRelationDto;
@ApiProperty()
latitude!: number;
@ApiProperty()
longitude!: number;
@ApiProperty()
recordedAt!: number;
}
export class TimelineActivityDto {
@ApiProperty({ format: 'uuid' })
id!: string;
@ApiProperty({ type: DefaultRelationDto })
employee!: DefaultRelationDto;
@ApiProperty({ type: DefaultRelationDto, nullable: true })
customer!: DefaultRelationDto | null;
@ApiProperty({ format: 'uuid', nullable: true })
visitId!: string | null;
@ApiProperty({ enum: TIMELINE_ACTIVITY_TYPES })
type!: string;
@ApiProperty()
sourceType!: string;
@ApiProperty({ format: 'uuid' })
sourceId!: string;
@ApiProperty()
latitude!: number;
@ApiProperty()
longitude!: number;
@ApiProperty()
recordedAt!: number;
}
export class TimelineDayDto {
@ApiProperty()
date!: string;
@ApiProperty({ type: [TimelineFootprintDto] })
footprints!: TimelineFootprintDto[];
@ApiProperty({ type: [TimelineActivityDto] })
activities!: TimelineActivityDto[];
}
export class TimelineMeDto {
@ApiProperty()
date!: string;
@ApiProperty({ type: [TimelineActivityDto] })
activities!: TimelineActivityDto[];
}
void PaginationMetaDto;
@@ -0,0 +1,130 @@
import { Inject, Injectable } from '@nestjs/common';
import { and, eq, gte, isNull, lte, sql } from 'drizzle-orm';
import { DRIZZLE, type DrizzleDB } from '../../../database/database.module';
import { customers } from '../../../database/customers-table';
import {
timelineActivities,
type TimelineActivityRow,
type TimelineActivityType,
} from '../../../database/timeline-activities-table';
import { visits } from '../../../database/visits-table';
import { employees } from '../../../database/schema';
import type {
ListTimelineFilters,
RecordTimelineActivityInput,
TimelineActivity,
} from './timeline.types';
@Injectable()
export class TimelineActivitiesRepository {
constructor(@Inject(DRIZZLE) private readonly db: DrizzleDB) {}
async insert(input: RecordTimelineActivityInput): Promise<TimelineActivity> {
const recordedAt = input.recordedAt ?? Date.now();
const inserted = await this.db
.insert(timelineActivities)
.values({
employeeId: input.employeeId,
customerId: input.customerId ?? null,
visitId: input.visitId ?? null,
type: input.type,
sourceType: input.sourceType,
sourceId: input.sourceId,
latitude: input.latitude,
longitude: input.longitude,
recordedAt,
})
.returning();
const row = inserted[0];
return {
id: row.id,
employeeId: row.employeeId,
customerId: row.customerId,
visitId: row.visitId,
type: row.type as TimelineActivityType,
sourceType: row.sourceType,
sourceId: row.sourceId,
latitude: row.latitude,
longitude: row.longitude,
recordedAt: row.recordedAt,
};
}
async findOpenVisitForEmployee(
employeeId: string,
): Promise<{ visitId: string; customerId: string } | null> {
const rows = await this.db
.select({
visitId: visits.id,
customerId: visits.customerId,
})
.from(visits)
.where(
and(
eq(visits.employeeId, employeeId),
isNull(visits.checkOutAt),
sql`${visits.status} <> 'archived'`,
),
)
.limit(1);
const row = rows[0];
return row ? { visitId: row.visitId, customerId: row.customerId } : null;
}
async list(filters: ListTimelineFilters): Promise<TimelineActivity[]> {
const conditions = [
gte(timelineActivities.recordedAt, filters.dayStartMs),
lte(timelineActivities.recordedAt, filters.dayEndMs),
];
if (filters.employeeId) {
conditions.push(eq(timelineActivities.employeeId, filters.employeeId));
}
const rows = await this.db
.select({
activity: timelineActivities,
employee: employees,
customer: customers,
})
.from(timelineActivities)
.innerJoin(employees, eq(timelineActivities.employeeId, employees.id))
.leftJoin(customers, eq(timelineActivities.customerId, customers.id))
.where(and(...conditions))
.orderBy(timelineActivities.recordedAt);
return rows.map((row) =>
this.toDomain(row.activity, row.employee, row.customer),
);
}
private toDomain(
row: TimelineActivityRow,
employee?: typeof employees.$inferSelect,
customer?: typeof customers.$inferSelect | null,
): TimelineActivity {
return {
id: row.id,
employeeId: row.employeeId,
customerId: row.customerId,
visitId: row.visitId,
type: row.type as TimelineActivityType,
sourceType: row.sourceType,
sourceId: row.sourceId,
latitude: row.latitude,
longitude: row.longitude,
recordedAt: row.recordedAt,
...(employee
? {
employee: {
id: employee.id,
code: employee.code,
name: employee.name,
},
}
: {}),
customer: customer
? { id: customer.id, code: customer.code, name: customer.name }
: null,
};
}
}
@@ -0,0 +1,61 @@
import { Injectable } from '@nestjs/common';
import type { TimelineActivityType } from '../../../database/timeline-activities-table';
import { TimelineActivitiesRepository } from './timeline-activities.repository';
import type { RecordTimelineActivityInput } from './timeline.types';
@Injectable()
export class TimelineActivitiesService {
constructor(
private readonly timelineActivitiesRepository: TimelineActivitiesRepository,
) {}
async record(input: RecordTimelineActivityInput): Promise<void> {
const openVisit =
input.visitId === undefined && input.customerId === undefined
? await this.timelineActivitiesRepository.findOpenVisitForEmployee(
input.employeeId,
)
: null;
await this.timelineActivitiesRepository.insert({
...input,
visitId: input.visitId ?? openVisit?.visitId ?? null,
customerId:
input.customerId ??
openVisit?.customerId ??
(input.type === 'customer_created' ? input.sourceId : null),
});
}
async recordIfLocated(input: {
readonly employeeId: string;
readonly type: TimelineActivityType;
readonly sourceType: string;
readonly sourceId: string;
readonly latitude?: number | null;
readonly longitude?: number | null;
readonly recordedAt?: number;
readonly customerId?: string | null;
readonly visitId?: string | null;
}): Promise<void> {
if (
input.latitude === undefined ||
input.latitude === null ||
input.longitude === undefined ||
input.longitude === null
) {
return;
}
await this.record({
employeeId: input.employeeId,
type: input.type,
sourceType: input.sourceType,
sourceId: input.sourceId,
latitude: input.latitude,
longitude: input.longitude,
recordedAt: input.recordedAt,
customerId: input.customerId,
visitId: input.visitId,
});
}
}
@@ -0,0 +1,82 @@
import { Inject, Injectable } from '@nestjs/common';
import { and, eq, gte, lte } from 'drizzle-orm';
import { DRIZZLE, type DrizzleDB } from '../../../database/database.module';
import {
timelineFootprints,
type TimelineFootprintRow,
} from '../../../database/timeline-footprints-table';
import { employees } from '../../../database/schema';
import type {
IngestFootprintPoint,
ListTimelineFilters,
TimelineFootprint,
} from './timeline.types';
@Injectable()
export class TimelineFootprintsRepository {
constructor(@Inject(DRIZZLE) private readonly db: DrizzleDB) {}
async insertMany(
employeeId: string,
points: readonly IngestFootprintPoint[],
): Promise<number> {
if (points.length === 0) {
return 0;
}
const rows = points.map((point) => ({
employeeId,
latitude: point.latitude,
longitude: point.longitude,
recordedAt: point.recordedAt,
}));
const inserted = await this.db
.insert(timelineFootprints)
.values(rows)
.returning({ id: timelineFootprints.id });
return inserted.length;
}
async list(filters: ListTimelineFilters): Promise<TimelineFootprint[]> {
const conditions = [
gte(timelineFootprints.recordedAt, filters.dayStartMs),
lte(timelineFootprints.recordedAt, filters.dayEndMs),
];
if (filters.employeeId) {
conditions.push(eq(timelineFootprints.employeeId, filters.employeeId));
}
const rows = await this.db
.select({
footprint: timelineFootprints,
employee: employees,
})
.from(timelineFootprints)
.innerJoin(employees, eq(timelineFootprints.employeeId, employees.id))
.where(and(...conditions))
.orderBy(timelineFootprints.recordedAt);
return rows.map((row) => this.toDomain(row.footprint, row.employee));
}
private toDomain(
row: TimelineFootprintRow,
employee?: typeof employees.$inferSelect,
): TimelineFootprint {
return {
id: row.id,
employeeId: row.employeeId,
latitude: row.latitude,
longitude: row.longitude,
recordedAt: row.recordedAt,
...(employee
? {
employee: {
id: employee.id,
code: employee.code,
name: employee.name,
},
}
: {}),
};
}
}
@@ -0,0 +1,67 @@
import { Controller, Get, Query } from '@nestjs/common';
import {
ApiBearerAuth,
ApiForbiddenResponse,
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 {
ADMIN_SALES_TIMELINE_PRIVILEGE_KEY,
FIELD_ATTENDANCE_PRIVILEGE_KEY,
MOBILE_SALES_TIMELINE_PRIVILEGE_KEY,
} from '../shared/field-purpose';
import {
ListTimelineQueryDto,
TimelineConfigDto,
TimelineDayDto,
TimelineMeDto,
} from './dto/timeline.dto';
import { TimelineService } from './timeline.service';
@ApiTags('timeline')
@ApiBearerAuth(BEARER_AUTH_NAME)
@Controller('timeline')
export class TimelineReadController {
constructor(private readonly timelineService: TimelineService) {}
@Get('config')
@RequirePrivilege(
[MOBILE_SALES_TIMELINE_PRIVILEGE_KEY, FIELD_ATTENDANCE_PRIVILEGE_KEY],
'view',
)
@ApiOperation({ summary: 'Get timeline tracking configuration' })
@ApiOkResponse({ type: TimelineConfigDto })
@ApiUnauthorizedResponse()
@ApiForbiddenResponse()
config(): Promise<TimelineConfigDto> {
return this.timelineService.getConfig();
}
@Get('me')
@RequirePrivilege(MOBILE_SALES_TIMELINE_PRIVILEGE_KEY, 'view')
@ApiOperation({ summary: 'Get current user timeline activities for a day' })
@ApiOkResponse({ type: TimelineMeDto })
@ApiUnauthorizedResponse()
@ApiForbiddenResponse()
me(
@Query() query: ListTimelineQueryDto,
@CurrentUser('id') userId: string,
): Promise<TimelineMeDto> {
return this.timelineService.getMyDay(query, userId);
}
@Get()
@RequirePrivilege(ADMIN_SALES_TIMELINE_PRIVILEGE_KEY, 'view')
@ApiOperation({ summary: 'Get timeline footprints and activities for a day' })
@ApiOkResponse({ type: TimelineDayDto })
@ApiUnauthorizedResponse()
@ApiForbiddenResponse()
day(@Query() query: ListTimelineQueryDto): Promise<TimelineDayDto> {
return this.timelineService.getDay(query);
}
}
@@ -0,0 +1,38 @@
import { Body, Controller, Post } from '@nestjs/common';
import {
ApiBearerAuth,
ApiCreatedResponse,
ApiForbiddenResponse,
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 { MOBILE_SALES_TIMELINE_PRIVILEGE_KEY } from '../shared/field-purpose';
import {
IngestTimelineFootprintsDto,
IngestTimelineFootprintsResultDto,
} from './dto/timeline.dto';
import { TimelineService } from './timeline.service';
@ApiTags('timeline')
@ApiBearerAuth(BEARER_AUTH_NAME)
@Controller('timeline')
export class TimelineWriteController {
constructor(private readonly timelineService: TimelineService) {}
@Post('footprints')
@RequirePrivilege(MOBILE_SALES_TIMELINE_PRIVILEGE_KEY, 'create')
@ApiOperation({ summary: 'Ingest GPS footprint points' })
@ApiCreatedResponse({ type: IngestTimelineFootprintsResultDto })
@ApiUnauthorizedResponse()
@ApiForbiddenResponse()
ingestFootprints(
@Body() dto: IngestTimelineFootprintsDto,
@CurrentUser('id') userId: string,
): Promise<IngestTimelineFootprintsResultDto> {
return this.timelineService.ingestFootprints(dto, userId);
}
}
@@ -0,0 +1,25 @@
import { Module } from '@nestjs/common';
import { EmployeesModule } from '../../configuration/employees/employees.module';
import { CompanySettingsRepository } from '../settings/company-settings.repository';
import { CompanySettingsService } from '../settings/company-settings.service';
import { TimelineActivitiesRepository } from './timeline-activities.repository';
import { TimelineActivitiesService } from './timeline-activities.service';
import { TimelineFootprintsRepository } from './timeline-footprints.repository';
import { TimelineReadController } from './timeline-read.controller';
import { TimelineWriteController } from './timeline-write.controller';
import { TimelineService } from './timeline.service';
@Module({
imports: [EmployeesModule],
controllers: [TimelineReadController, TimelineWriteController],
providers: [
CompanySettingsRepository,
CompanySettingsService,
TimelineFootprintsRepository,
TimelineActivitiesRepository,
TimelineActivitiesService,
TimelineService,
],
exports: [TimelineActivitiesService, TimelineService],
})
export class TimelineModule {}
@@ -0,0 +1,129 @@
import { BadRequestException } from '@nestjs/common';
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 { EmployeesService } from '../../configuration/employees/employees.service';
import type { CompanySetting } from '../settings/company-setting';
import { CompanySettingsService } from '../settings/company-settings.service';
import { TimelineActivitiesRepository } from './timeline-activities.repository';
import { TimelineFootprintsRepository } from './timeline-footprints.repository';
import { TimelineService } from './timeline.service';
describe('TimelineService', () => {
let service: TimelineService;
let footprintsRepository: jest.Mocked<
Pick<TimelineFootprintsRepository, 'insertMany' | 'list'>
>;
let activitiesRepository: jest.Mocked<
Pick<TimelineActivitiesRepository, 'list'>
>;
let employeesService: jest.Mocked<Pick<EmployeesService, 'requireByUserId'>>;
let companySettingsService: jest.Mocked<
Pick<CompanySettingsService, 'requireTimelineConfig'>
>;
const nowMs = DateTime.fromUnixMs(Date.now()).startOfDay().value + 3_600_000;
beforeEach(async () => {
footprintsRepository = {
insertMany: jest.fn(),
list: jest.fn(),
};
activitiesRepository = {
list: jest.fn(),
};
employeesService = {
requireByUserId: jest.fn(),
};
companySettingsService = {
requireTimelineConfig: jest.fn(),
};
const moduleRef: TestingModule = await Test.createTestingModule({
providers: [
TimelineService,
{
provide: TimelineFootprintsRepository,
useValue: footprintsRepository,
},
{
provide: TimelineActivitiesRepository,
useValue: activitiesRepository,
},
{ provide: EmployeesService, useValue: employeesService },
{ provide: CompanySettingsService, useValue: companySettingsService },
],
}).compile();
service = moduleRef.get(TimelineService);
companySettingsService.requireTimelineConfig.mockResolvedValue({
gpsIntervalSeconds: 5,
checkoutWarningRadiusMeters: 200,
});
});
it('returns timeline config from company settings', async () => {
await expect(service.getConfig()).resolves.toEqual({
gpsIntervalSeconds: 5,
checkoutWarningRadiusMeters: 200,
});
});
it('ingests footprint points for the current employee', async () => {
employeesService.requireByUserId.mockResolvedValue({
id: 'emp-1',
} as Awaited<ReturnType<EmployeesService['requireByUserId']>>);
footprintsRepository.insertMany.mockResolvedValue(1);
const result = await service.ingestFootprints(
{
points: [{ latitude: -6.2, longitude: 106.8, recordedAt: nowMs }],
},
'user-1',
);
expect(result).toEqual({ inserted: 1 });
expect(footprintsRepository.insertMany).toHaveBeenCalledWith('emp-1', [
{ latitude: -6.2, longitude: 106.8, recordedAt: nowMs },
]);
});
it('rejects stale footprint timestamps', async () => {
employeesService.requireByUserId.mockResolvedValue({
id: 'emp-1',
} as Awaited<ReturnType<EmployeesService['requireByUserId']>>);
await expect(
service.ingestFootprints(
{
points: [
{
latitude: -6.2,
longitude: 106.8,
recordedAt: Date.now() - 25 * 60 * 60 * 1000,
},
],
},
'user-1',
),
).rejects.toBeInstanceOf(BadRequestException);
});
it('downsamples footprints when querying all employees', async () => {
const footprints = Array.from({ length: 4000 }, (_, index) => ({
id: `fp-${index}`,
employeeId: 'emp-1',
latitude: -6.2,
longitude: 106.8 + index * 0.0001,
recordedAt: nowMs + index * 1000,
employee: { id: 'emp-1', code: 'E1', name: 'Ada' },
}));
footprintsRepository.list.mockResolvedValue(footprints);
activitiesRepository.list.mockResolvedValue([]);
const result = await service.getDay({ date: '2026-09-01' });
expect(result.footprints.length).toBeLessThanOrEqual(2000);
expect(result.activities).toEqual([]);
});
});
@@ -0,0 +1,179 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import {
DEFAULT_RELATION_FIELDS,
pickRelation,
} from '../../../common/http/response';
import { InvalidDateTimeError } from '../../../common/value-objects/date-time/invalid-date-time.error';
import { DateTime } from '../../../common/value-objects/date-time/date-time';
import { EmployeesService } from '../../configuration/employees/employees.service';
import { CompanySettingsService } from '../settings/company-settings.service';
import type {
IngestTimelineFootprintsDto,
ListTimelineQueryDto,
TimelineActivityDto,
TimelineConfigDto,
TimelineDayDto,
TimelineFootprintDto,
TimelineMeDto,
} from './dto/timeline.dto';
import { TimelineActivitiesRepository } from './timeline-activities.repository';
import { TimelineFootprintsRepository } from './timeline-footprints.repository';
import type { TimelineActivity, TimelineFootprint } from './timeline.types';
const MS_PER_DAY = 86_400_000;
const MAX_FOOTPRINTS_ALL_EMPLOYEES = 2000;
const MAX_FOOTPRINT_AGE_MS = 24 * 60 * 60 * 1000;
@Injectable()
export class TimelineService {
constructor(
private readonly timelineFootprintsRepository: TimelineFootprintsRepository,
private readonly timelineActivitiesRepository: TimelineActivitiesRepository,
private readonly employeesService: EmployeesService,
private readonly companySettingsService: CompanySettingsService,
) {}
async getConfig(): Promise<TimelineConfigDto> {
return this.companySettingsService.requireTimelineConfig();
}
async ingestFootprints(
dto: IngestTimelineFootprintsDto,
userId: string,
): Promise<{ inserted: number }> {
const employee = await this.employeesService.requireByUserId(userId);
const now = Date.now();
const points = dto.points.map((point) => {
this.assertCoordinates(point.latitude, point.longitude);
if (now - point.recordedAt > MAX_FOOTPRINT_AGE_MS) {
throw new BadRequestException('Footprint timestamp is too old');
}
if (point.recordedAt > now + 60_000) {
throw new BadRequestException('Footprint timestamp is in the future');
}
return point;
});
const inserted = await this.timelineFootprintsRepository.insertMany(
employee.id,
points,
);
return { inserted };
}
async getDay(query: ListTimelineQueryDto): Promise<TimelineDayDto> {
const { dateLabel, filters } = this.resolveDayFilters(query);
const [footprints, activities] = await Promise.all([
this.timelineFootprintsRepository.list(filters),
this.timelineActivitiesRepository.list(filters),
]);
return {
date: dateLabel,
footprints: this.downsampleFootprints(footprints, query.employeeId).map(
(item) => this.toFootprintDto(item),
),
activities: activities.map((item) => this.toActivityDto(item)),
};
}
async getMyDay(
query: ListTimelineQueryDto,
userId: string,
): Promise<TimelineMeDto> {
const employee = await this.employeesService.requireByUserId(userId);
const { dateLabel, filters } = this.resolveDayFilters({
...query,
employeeId: employee.id,
});
const activities = await this.timelineActivitiesRepository.list(filters);
return {
date: dateLabel,
activities: activities.map((item) => this.toActivityDto(item)),
};
}
private resolveDayFilters(query: ListTimelineQueryDto): {
dateLabel: string;
filters: {
dayStartMs: number;
dayEndMs: number;
employeeId?: string;
};
} {
const dateLabel = this.resolveDateLabel(query.date);
const dayStart = DateTime.create(dateLabel).startOfDay();
return {
dateLabel,
filters: {
dayStartMs: dayStart.value,
dayEndMs: dayStart.value + MS_PER_DAY - 1,
employeeId: query.employeeId,
},
};
}
private resolveDateLabel(raw?: string): string {
if (!raw) {
return DateTime.fromUnixMs(Date.now()).startOfDay().format().slice(0, 10);
}
try {
DateTime.create(raw);
return raw;
} catch (error) {
if (error instanceof InvalidDateTimeError) {
throw new BadRequestException('Invalid date');
}
throw error;
}
}
private downsampleFootprints(
footprints: readonly TimelineFootprint[],
employeeId?: string,
): TimelineFootprint[] {
if (employeeId || footprints.length <= MAX_FOOTPRINTS_ALL_EMPLOYEES) {
return [...footprints];
}
const stride = Math.ceil(footprints.length / MAX_FOOTPRINTS_ALL_EMPLOYEES);
return footprints.filter((_, index) => index % stride === 0);
}
private assertCoordinates(latitude: number, longitude: number): void {
if (
latitude < -90 ||
latitude > 90 ||
longitude < -180 ||
longitude > 180
) {
throw new BadRequestException('Invalid coordinates');
}
}
private toFootprintDto(footprint: TimelineFootprint): TimelineFootprintDto {
return {
id: footprint.id,
employee: pickRelation(footprint.employee, DEFAULT_RELATION_FIELDS)!,
latitude: footprint.latitude,
longitude: footprint.longitude,
recordedAt: footprint.recordedAt,
};
}
private toActivityDto(activity: TimelineActivity): TimelineActivityDto {
return {
id: activity.id,
employee: pickRelation(activity.employee, DEFAULT_RELATION_FIELDS)!,
customer: activity.customer
? pickRelation(activity.customer, DEFAULT_RELATION_FIELDS)
: null,
visitId: activity.visitId,
type: activity.type,
sourceType: activity.sourceType,
sourceId: activity.sourceId,
latitude: activity.latitude,
longitude: activity.longitude,
recordedAt: activity.recordedAt,
};
}
}
@@ -0,0 +1,61 @@
import type { TimelineActivityType } from '../../../database/timeline-activities-table';
export type TimelineFootprint = {
readonly id: string;
readonly employeeId: string;
readonly latitude: number;
readonly longitude: number;
readonly recordedAt: number;
readonly employee?: {
readonly id: string;
readonly code: string;
readonly name: string;
};
};
export type TimelineActivity = {
readonly id: string;
readonly employeeId: string;
readonly customerId: string | null;
readonly visitId: string | null;
readonly type: TimelineActivityType;
readonly sourceType: string;
readonly sourceId: string;
readonly latitude: number;
readonly longitude: number;
readonly recordedAt: number;
readonly employee?: {
readonly id: string;
readonly code: string;
readonly name: string;
};
readonly customer?: {
readonly id: string;
readonly code: string;
readonly name: string;
} | null;
};
export type RecordTimelineActivityInput = {
readonly employeeId: string;
readonly type: TimelineActivityType;
readonly sourceType: string;
readonly sourceId: string;
readonly latitude: number;
readonly longitude: number;
readonly recordedAt?: number;
readonly customerId?: string | null;
readonly visitId?: string | null;
};
export type IngestFootprintPoint = {
readonly latitude: number;
readonly longitude: number;
readonly recordedAt: number;
};
export type ListTimelineFilters = {
readonly dayStartMs: number;
readonly dayEndMs: number;
readonly employeeId?: string;
};
+230
View File
@@ -0,0 +1,230 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
ArrayNotEmpty,
IsArray,
IsIn,
IsLatitude,
IsLongitude,
IsNumber,
IsOptional,
IsString,
IsUrl,
IsUUID,
ValidateIf,
} from 'class-validator';
import { PaginationQueryDto } from '../../../../common/http/response';
import { CORE_STATUSES } from '../../../../common/value-objects/status/status';
import { CHECK_IN_METHODS } from '../../shared/check-in-verification';
export class VisitCheckInDto {
@ApiProperty({ format: 'uuid' })
@IsUUID('4')
customerId!: string;
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID('4')
planId?: string;
@ApiProperty({ enum: CHECK_IN_METHODS })
@IsIn([...CHECK_IN_METHODS])
method!: string;
@ApiPropertyOptional()
@ValidateIf((dto: VisitCheckInDto) => dto.method === 'nfc')
@IsString()
nfcId?: string;
@ApiPropertyOptional()
@ValidateIf((dto: VisitCheckInDto) => dto.method === 'qr')
@IsString()
qrCode?: string;
@ApiProperty()
@IsLatitude()
latitude!: number;
@ApiProperty()
@IsLongitude()
longitude!: number;
@ApiPropertyOptional()
@IsOptional()
@IsUrl({ require_protocol: true, protocols: ['https'] })
photoUrl?: string;
}
export class VisitCheckOutDto {
@ApiProperty({ enum: CHECK_IN_METHODS })
@IsIn([...CHECK_IN_METHODS])
method!: string;
@ApiPropertyOptional()
@ValidateIf((dto: VisitCheckOutDto) => dto.method === 'nfc')
@IsString()
nfcId?: string;
@ApiPropertyOptional()
@ValidateIf((dto: VisitCheckOutDto) => dto.method === 'qr')
@IsString()
qrCode?: string;
@ApiProperty()
@IsLatitude()
latitude!: number;
@ApiProperty()
@IsLongitude()
longitude!: number;
@ApiPropertyOptional()
@IsOptional()
@IsUrl({ require_protocol: true, protocols: ['https'] })
photoUrl?: string;
}
export class UpdateVisitStatusDto {
@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 ListVisitsQueryDto extends PaginationQueryDto {
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID('4')
employeeId?: string;
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID('4')
customerId?: string;
@ApiPropertyOptional({ description: 'Unix ms start of calendar day' })
@IsOptional()
@IsNumber()
date?: number;
@ApiPropertyOptional({ enum: CORE_STATUSES })
@IsOptional()
@IsIn([...CORE_STATUSES])
status?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
search?: string;
}
class RelationDto {
@ApiProperty({ format: 'uuid' })
id!: string;
@ApiProperty()
code!: string;
@ApiProperty()
name!: string;
}
class UserRelationDto {
@ApiProperty({ format: 'uuid' })
id!: string;
@ApiProperty()
username!: string;
}
export class VisitDto {
@ApiProperty({ format: 'uuid' })
id!: string;
@ApiProperty({ type: RelationDto })
employee!: RelationDto;
@ApiProperty({ type: RelationDto })
customer!: RelationDto;
@ApiProperty({ format: 'uuid', nullable: true })
attendanceId!: string | null;
@ApiProperty({ format: 'uuid', nullable: true })
planId!: string | null;
@ApiProperty({ format: 'uuid', nullable: true })
planDestinationId!: string | null;
@ApiProperty()
date!: number;
@ApiProperty()
checkInAt!: number;
@ApiProperty({ enum: CHECK_IN_METHODS })
checkInMethod!: string;
@ApiProperty()
checkInLatitude!: number;
@ApiProperty()
checkInLongitude!: number;
@ApiProperty({ nullable: true })
checkInPhotoUrl!: string | null;
@ApiProperty({ nullable: true })
checkInDistanceMeters!: number | null;
@ApiProperty({ nullable: true })
checkOutAt!: number | null;
@ApiProperty({ enum: CHECK_IN_METHODS, nullable: true })
checkOutMethod!: string | null;
@ApiProperty({ nullable: true })
checkOutLatitude!: number | null;
@ApiProperty({ nullable: true })
checkOutLongitude!: number | null;
@ApiProperty({ nullable: true })
checkOutPhotoUrl!: string | null;
@ApiProperty({ nullable: true })
checkOutDistanceMeters!: number | null;
@ApiProperty()
status!: string;
@ApiProperty()
createdAt!: number;
@ApiProperty()
updatedAt!: number;
@ApiProperty({ type: UserRelationDto })
createdBy!: UserRelationDto;
@ApiProperty({ type: UserRelationDto })
updatedBy!: UserRelationDto;
}
+72
View File
@@ -0,0 +1,72 @@
import { DateTime } from '../../../common/value-objects/date-time/date-time';
import { Status } from '../../../common/value-objects/status/status';
import type { CheckInMethod } from '../shared/check-in-verification';
export type Visit = {
readonly id: string;
readonly employeeId: string;
readonly customerId: string;
readonly attendanceId: string | null;
readonly planId: string | null;
readonly planDestinationId: string | null;
readonly date: DateTime;
readonly checkInAt: DateTime;
readonly checkInMethod: CheckInMethod;
readonly checkInLatitude: number;
readonly checkInLongitude: number;
readonly checkInPhotoUrl: string | null;
readonly checkInDistanceMeters: number | null;
readonly checkOutAt: DateTime | null;
readonly checkOutMethod: CheckInMethod | null;
readonly checkOutLatitude: number | null;
readonly checkOutLongitude: number | null;
readonly checkOutPhotoUrl: string | null;
readonly checkOutDistanceMeters: number | null;
readonly status: Status;
readonly createdAt: DateTime;
readonly updatedAt: DateTime;
readonly createdBy: string;
readonly updatedBy: string;
readonly employee: { id: string; code: string; name: string } | null;
readonly customer: { id: string; code: string; name: string } | null;
readonly createdByUser: { id: string; username: string } | null;
readonly updatedByUser: { id: string; username: string } | null;
};
export type ListVisitsFilters = {
readonly employeeId?: string;
readonly customerId?: string;
readonly date?: number;
readonly status?: string;
readonly search?: string;
readonly orderBy?: string;
readonly orderType?: string;
readonly limit: number;
readonly offset: number;
};
export type CreateVisitInput = {
readonly employeeId: string;
readonly customerId: string;
readonly attendanceId: string | null;
readonly planId: string | null;
readonly planDestinationId: string | null;
readonly date: DateTime;
readonly checkInAt: DateTime;
readonly checkInMethod: CheckInMethod;
readonly checkInLatitude: number;
readonly checkInLongitude: number;
readonly checkInPhotoUrl: string | null;
readonly checkInDistanceMeters: number | null;
readonly userId: string;
};
export type CheckOutVisitInput = {
readonly checkOutAt: DateTime;
readonly checkOutMethod: CheckInMethod;
readonly checkOutLatitude: number;
readonly checkOutLongitude: number;
readonly checkOutPhotoUrl: string | null;
readonly checkOutDistanceMeters: number | null;
readonly userId: string;
};
@@ -0,0 +1,74 @@
import { Controller, Get, Param, ParseUUIDPipe, Query } from '@nestjs/common';
import {
ApiBearerAuth,
ApiForbiddenResponse,
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 {
Pagination,
type PaginationResponse,
PaginationMetaDto,
} from '../../../common/http/response';
import { BEARER_AUTH_NAME } from '../../../common/swagger/setup-swagger';
import { FIELD_VISIT_PRIVILEGE_KEY } from '../shared/field-purpose';
import { ListVisitsQueryDto, VisitDto } from './dto/visit.dto';
import { VisitsService } from './visits.service';
@ApiTags('visits')
@ApiBearerAuth(BEARER_AUTH_NAME)
@Controller('visits')
export class VisitsReadController {
constructor(private readonly visitsService: VisitsService) {}
@Get('current')
@RequirePrivilege(FIELD_VISIT_PRIVILEGE_KEY, 'view')
@ApiOperation({ summary: 'Get the open visit for the current user' })
@ApiOkResponse({ type: VisitDto })
@ApiUnauthorizedResponse()
@ApiForbiddenResponse()
current(@CurrentUser('id') userId: string): Promise<VisitDto | null> {
return this.visitsService.findCurrent(userId);
}
@Get()
@Pagination()
@RequirePrivilege(FIELD_VISIT_PRIVILEGE_KEY, 'view')
@ApiOperation({ summary: 'List visits' })
@ApiOkResponse({
schema: {
properties: {
data: {
type: 'array',
items: { $ref: '#/components/schemas/VisitDto' },
},
meta: { $ref: '#/components/schemas/PaginationMetaDto' },
},
},
})
@ApiUnauthorizedResponse()
@ApiForbiddenResponse()
list(
@Query() query: ListVisitsQueryDto,
): Promise<PaginationResponse<VisitDto>> {
return this.visitsService.list(query);
}
@Get(':id')
@RequirePrivilege(FIELD_VISIT_PRIVILEGE_KEY, 'view')
@ApiOperation({ summary: 'Get visit detail' })
@ApiOkResponse({ type: VisitDto })
@ApiNotFoundResponse()
@ApiUnauthorizedResponse()
@ApiForbiddenResponse()
findOne(@Param('id', ParseUUIDPipe) id: string): Promise<VisitDto> {
return this.visitsService.findById(id);
}
}
void PaginationMetaDto;
@@ -0,0 +1,112 @@
import {
Body,
Controller,
Delete,
HttpCode,
Param,
ParseUUIDPipe,
Patch,
Post,
} from '@nestjs/common';
import {
ApiBearerAuth,
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 { FIELD_VISIT_PRIVILEGE_KEY } from '../shared/field-purpose';
import {
BulkIdsDto,
BulkStatusDto,
UpdateVisitStatusDto,
VisitCheckInDto,
VisitCheckOutDto,
VisitDto,
} from './dto/visit.dto';
import { VisitsService } from './visits.service';
@ApiTags('visits')
@ApiBearerAuth(BEARER_AUTH_NAME)
@Controller('visits')
export class VisitsWriteController {
constructor(private readonly visitsService: VisitsService) {}
@Post('check-in')
@RequirePrivilege(FIELD_VISIT_PRIVILEGE_KEY, 'create')
@ApiOperation({ summary: 'Check in at a customer location' })
@ApiCreatedResponse({ type: VisitDto })
@ApiUnauthorizedResponse()
@ApiForbiddenResponse()
checkIn(
@Body() dto: VisitCheckInDto,
@CurrentUser('id') userId: string,
): Promise<VisitDto> {
return this.visitsService.checkIn(dto, userId);
}
@Post('bulk-delete')
@HttpCode(200)
@RequirePrivilege(FIELD_VISIT_PRIVILEGE_KEY, 'delete')
@ApiOperation({ summary: 'Bulk delete visits' })
@ApiOkResponse({ schema: { properties: { deleted: { type: 'number' } } } })
bulkDelete(@Body() dto: BulkIdsDto): Promise<{ deleted: number }> {
return this.visitsService.bulkDelete(dto.ids);
}
@Post('bulk-status')
@HttpCode(200)
@RequirePrivilege(FIELD_VISIT_PRIVILEGE_KEY, 'update')
@ApiOperation({ summary: 'Bulk update visit status' })
@ApiOkResponse({ schema: { properties: { updated: { type: 'number' } } } })
bulkStatus(
@Body() dto: BulkStatusDto,
@CurrentUser('id') userId: string,
): Promise<{ updated: number }> {
return this.visitsService.bulkUpdateStatus(dto.ids, dto.status, userId);
}
@Post(':id/check-out')
@RequirePrivilege(FIELD_VISIT_PRIVILEGE_KEY, 'update')
@ApiOperation({ summary: 'Check out from a customer visit' })
@ApiOkResponse({ type: VisitDto })
@ApiNotFoundResponse()
@ApiUnauthorizedResponse()
@ApiForbiddenResponse()
checkOut(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: VisitCheckOutDto,
@CurrentUser('id') userId: string,
): Promise<VisitDto> {
return this.visitsService.checkOut(id, dto, userId);
}
@Patch(':id/status')
@RequirePrivilege(FIELD_VISIT_PRIVILEGE_KEY, 'update')
@ApiOperation({ summary: 'Update visit status' })
@ApiOkResponse({ type: VisitDto })
updateStatus(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateVisitStatusDto,
@CurrentUser('id') userId: string,
): Promise<VisitDto> {
return this.visitsService.updateStatus(id, dto.status, userId);
}
@Delete(':id')
@HttpCode(204)
@RequirePrivilege(FIELD_VISIT_PRIVILEGE_KEY, 'delete')
@ApiOperation({ summary: 'Delete visit' })
@ApiNoContentResponse()
@ApiNotFoundResponse()
delete(@Param('id', ParseUUIDPipe) id: string): Promise<void> {
return this.visitsService.delete(id);
}
}
@@ -0,0 +1,349 @@
import {
ConflictException,
Inject,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { and, count, eq, ilike, inArray, isNull, or, SQL } from 'drizzle-orm';
import { alias } from 'drizzle-orm/pg-core';
import { toOrderClauses } from '../../../common/http/response/order-clause';
import { DateTime } from '../../../common/value-objects/date-time/date-time';
import { Status } from '../../../common/value-objects/status/status';
import { DRIZZLE, type DrizzleDB } from '../../../database/database.module';
import { customers } from '../../../database/customers-table';
import { planDestinations } from '../../../database/plans-table';
import { visits, type VisitRow } from '../../../database/visits-table';
import { employees, users } from '../../../database/schema';
import type {
CheckOutVisitInput,
CreateVisitInput,
ListVisitsFilters,
Visit,
} from './visit';
import type { CheckInMethod } from '../shared/check-in-verification';
const VISIT_ORDER_COLUMNS = {
id: visits.id,
date: visits.date,
status: visits.status,
createdAt: visits.createdAt,
updatedAt: visits.updatedAt,
};
const createdByUsers = alias(users, 'visit_created_by_users');
const updatedByUsers = alias(users, 'visit_updated_by_users');
type VisitJoinedRow = {
visit: VisitRow;
employee: typeof employees.$inferSelect;
customer: typeof customers.$inferSelect;
createdByUser: typeof users.$inferSelect | null;
updatedByUser: typeof users.$inferSelect | null;
};
@Injectable()
export class VisitsRepository {
constructor(@Inject(DRIZZLE) private readonly db: DrizzleDB) {}
async list(
filters: ListVisitsFilters,
): Promise<{ data: Visit[]; total: number }> {
const where = this.buildListWhere(filters);
const totalRows = await this.db
.select({ total: count() })
.from(visits)
.where(where);
const totalRow = totalRows[0];
const rows = await this.selectWithRelations()
.where(where)
.orderBy(
...toOrderClauses(VISIT_ORDER_COLUMNS, filters, [
{ column: 'date', type: 'DESC' },
]),
)
.limit(filters.limit)
.offset(filters.offset);
return {
data: rows.map((row) => this.toDomain(row)),
total: Number(totalRow?.total ?? 0),
};
}
async findById(id: string): Promise<Visit | null> {
const rows = await this.selectWithRelations()
.where(eq(visits.id, id))
.limit(1);
const row = rows[0];
return row ? this.toDomain(row) : null;
}
async findOpenByEmployeeId(employeeId: string): Promise<Visit | null> {
const rows = await this.selectWithRelations()
.where(and(eq(visits.employeeId, employeeId), isNull(visits.checkOutAt)))
.limit(1);
const row = rows[0];
return row ? this.toDomain(row) : null;
}
async findPlanDestinationId(
planId: string,
customerId: string,
): Promise<string | null> {
const rows = await this.db
.select({ id: planDestinations.id })
.from(planDestinations)
.where(
and(
eq(planDestinations.planId, planId),
eq(planDestinations.customerId, customerId),
),
)
.limit(1);
return rows[0]?.id ?? null;
}
async create(input: CreateVisitInput): Promise<Visit> {
const now = DateTime.fromUnixMs(Date.now());
try {
const inserted = await this.db
.insert(visits)
.values({
employeeId: input.employeeId,
customerId: input.customerId,
attendanceId: input.attendanceId,
planId: input.planId,
planDestinationId: input.planDestinationId,
date: input.date.value,
checkInAt: input.checkInAt.value,
checkInMethod: input.checkInMethod,
checkInLatitude: input.checkInLatitude,
checkInLongitude: input.checkInLongitude,
checkInPhotoUrl: input.checkInPhotoUrl,
checkInDistanceMeters: input.checkInDistanceMeters,
status: Status.create('active').value,
createdAt: now.value,
updatedAt: now.value,
createdBy: input.userId,
updatedBy: input.userId,
})
.returning();
const created = await this.findById(inserted[0].id);
if (!created) {
throw new NotFoundException('Visit not found');
}
return created;
} catch (error) {
this.rethrowUniqueViolation(error);
}
}
async checkOut(id: string, input: CheckOutVisitInput): Promise<Visit> {
const existing = await this.findById(id);
if (!existing) {
throw new NotFoundException('Visit not found');
}
if (existing.checkOutAt) {
throw new ConflictException('Visit is already checked out');
}
const now = DateTime.fromUnixMs(Date.now());
await this.db
.update(visits)
.set({
checkOutAt: input.checkOutAt.value,
checkOutMethod: input.checkOutMethod,
checkOutLatitude: input.checkOutLatitude,
checkOutLongitude: input.checkOutLongitude,
checkOutPhotoUrl: input.checkOutPhotoUrl,
checkOutDistanceMeters: input.checkOutDistanceMeters,
updatedAt: now.value,
updatedBy: input.userId,
})
.where(eq(visits.id, id));
const updated = await this.findById(id);
if (!updated) {
throw new NotFoundException('Visit not found');
}
return updated;
}
async updateStatus(
id: string,
status: Status,
userId: string,
): Promise<Visit> {
const now = DateTime.fromUnixMs(Date.now());
const updated = await this.db
.update(visits)
.set({
status: status.value,
updatedAt: now.value,
updatedBy: userId,
})
.where(eq(visits.id, id))
.returning({ id: visits.id });
if (updated.length === 0) {
throw new NotFoundException('Visit not found');
}
const row = await this.findById(id);
if (!row) {
throw new NotFoundException('Visit not found');
}
return row;
}
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(visits)
.set({
status: status.value,
updatedAt: now.value,
updatedBy: userId,
})
.where(inArray(visits.id, ids))
.returning({ id: visits.id });
return rows.length;
}
async delete(id: string): Promise<void> {
const deleted = await this.db
.delete(visits)
.where(eq(visits.id, id))
.returning({ id: visits.id });
if (deleted.length === 0) {
throw new NotFoundException('Visit not found');
}
}
async bulkDelete(ids: string[]): Promise<number> {
if (ids.length === 0) {
return 0;
}
const deleted = await this.db
.delete(visits)
.where(inArray(visits.id, ids))
.returning({ id: visits.id });
return deleted.length;
}
private selectWithRelations() {
return this.db
.select({
visit: visits,
employee: employees,
customer: customers,
createdByUser: createdByUsers,
updatedByUser: updatedByUsers,
})
.from(visits)
.innerJoin(employees, eq(visits.employeeId, employees.id))
.innerJoin(customers, eq(visits.customerId, customers.id))
.leftJoin(createdByUsers, eq(visits.createdBy, createdByUsers.id))
.leftJoin(updatedByUsers, eq(visits.updatedBy, updatedByUsers.id));
}
private buildListWhere(filters: ListVisitsFilters): SQL | undefined {
const parts: SQL[] = [];
if (filters.employeeId) {
parts.push(eq(visits.employeeId, filters.employeeId));
}
if (filters.customerId) {
parts.push(eq(visits.customerId, filters.customerId));
}
if (filters.date !== undefined) {
parts.push(eq(visits.date, filters.date));
}
if (filters.status) {
parts.push(eq(visits.status, filters.status));
}
if (filters.search) {
const search = or(
ilike(employees.code, `%${filters.search}%`),
ilike(employees.name, `%${filters.search}%`),
ilike(customers.code, `%${filters.search}%`),
ilike(customers.name, `%${filters.search}%`),
);
if (search) {
parts.push(search);
}
}
if (parts.length === 0) {
return undefined;
}
return parts.length === 1 ? parts[0] : and(...parts);
}
private toDomain(row: VisitJoinedRow): Visit {
const visit = row.visit;
return {
id: visit.id,
employeeId: visit.employeeId,
customerId: visit.customerId,
attendanceId: visit.attendanceId,
planId: visit.planId,
planDestinationId: visit.planDestinationId,
date: DateTime.fromUnixMs(visit.date),
checkInAt: DateTime.fromUnixMs(visit.checkInAt),
checkInMethod: visit.checkInMethod as CheckInMethod,
checkInLatitude: visit.checkInLatitude,
checkInLongitude: visit.checkInLongitude,
checkInPhotoUrl: visit.checkInPhotoUrl,
checkInDistanceMeters: visit.checkInDistanceMeters,
checkOutAt: visit.checkOutAt
? DateTime.fromUnixMs(visit.checkOutAt)
: null,
checkOutMethod: visit.checkOutMethod as CheckInMethod | null,
checkOutLatitude: visit.checkOutLatitude,
checkOutLongitude: visit.checkOutLongitude,
checkOutPhotoUrl: visit.checkOutPhotoUrl,
checkOutDistanceMeters: visit.checkOutDistanceMeters,
status: Status.create(visit.status),
createdAt: DateTime.fromUnixMs(visit.createdAt),
updatedAt: DateTime.fromUnixMs(visit.updatedAt),
createdBy: visit.createdBy,
updatedBy: visit.updatedBy,
employee: {
id: row.employee.id,
code: row.employee.code,
name: row.employee.name,
},
customer: {
id: row.customer.id,
code: row.customer.code,
name: row.customer.name,
},
createdByUser: row.createdByUser
? { id: row.createdByUser.id, username: row.createdByUser.username }
: null,
updatedByUser: row.updatedByUser
? { id: row.updatedByUser.id, username: row.updatedByUser.username }
: null,
};
}
private rethrowUniqueViolation(error: unknown): never {
let current: unknown = error;
for (let i = 0; i < 5; i++) {
if (!current || typeof current !== 'object') {
break;
}
const obj = current as { code?: string; cause?: unknown };
if (obj.code === '23505') {
throw new ConflictException(
'A visit is already open for this employee',
);
}
current = obj.cause;
}
throw error;
}
}
+302
View File
@@ -0,0 +1,302 @@
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import type { PaginationResponse } from '../../../common/http/response';
import {
pickRelation,
pickUserRelation,
DEFAULT_RELATION_FIELDS,
toListPage,
} from '../../../common/http/response';
import { DateTime } from '../../../common/value-objects/date-time/date-time';
import { Status } from '../../../common/value-objects/status/status';
import { CustomersService } from '../../configuration/customers/customers.service';
import { EmployeesService } from '../../configuration/employees/employees.service';
import { AttendancesRepository } from '../attendances/attendances.repository';
import { CompanySettingsService } from '../settings/company-settings.service';
import {
isCheckInMethod,
verifyCustomerCheckIn,
type CheckInPayload,
type CheckInVerificationOptions,
} from '../shared/check-in-verification';
import { FIELD_VISIT_PRIVILEGE_KEY } from '../shared/field-purpose';
import { TimelineActivitiesService } from '../timeline/timeline-activities.service';
import type { Visit } from './visit';
import type {
ListVisitsQueryDto,
VisitCheckInDto,
VisitCheckOutDto,
VisitDto,
} from './dto/visit.dto';
import { VisitsRepository } from './visits.repository';
@Injectable()
export class VisitsService {
constructor(
private readonly visitsRepository: VisitsRepository,
private readonly attendancesRepository: AttendancesRepository,
private readonly customersService: CustomersService,
private readonly employeesService: EmployeesService,
private readonly companySettingsService: CompanySettingsService,
private readonly timelineActivitiesService: TimelineActivitiesService,
private readonly config: ConfigService,
) {}
async list(query: ListVisitsQueryDto): Promise<PaginationResponse<VisitDto>> {
const page = toListPage(query);
const { data, total } = await this.visitsRepository.list({
employeeId: query.employeeId,
customerId: query.customerId,
date: query.date,
status: query.status,
search: query.search,
orderBy: query.orderBy,
orderType: query.orderType,
limit: page.limit,
offset: page.offset,
});
return {
data: data.map((item) => this.toItem(item)),
total,
};
}
async findById(id: string): Promise<VisitDto> {
const visit = await this.visitsRepository.findById(id);
if (!visit) {
throw new NotFoundException('Visit not found');
}
return this.toItem(visit);
}
async findCurrent(userId: string): Promise<VisitDto | null> {
const employee = await this.employeesService.requireByUserId(userId);
const visit = await this.visitsRepository.findOpenByEmployeeId(employee.id);
return visit ? this.toItem(visit) : null;
}
async checkIn(dto: VisitCheckInDto, userId: string): Promise<VisitDto> {
const employee = await this.employeesService.requireByUserId(userId);
const openVisit = await this.visitsRepository.findOpenByEmployeeId(
employee.id,
);
if (openVisit) {
throw new ConflictException('A customer visit is already open');
}
const openAttendance =
await this.attendancesRepository.findOpenByEmployeeId(employee.id);
if (!openAttendance) {
throw new BadRequestException(
'Branch check-in is required before visiting a customer',
);
}
const customer = await this.customersService.findById(dto.customerId);
const radiusMeters =
await this.companySettingsService.requireCheckInRadiusMeters();
const payload = this.toPayload(dto);
const verified = verifyCustomerCheckIn(
{
code: customer.code,
nfcId: customer.nfcId,
latitude: customer.latitude,
longitude: customer.longitude,
},
payload,
radiusMeters,
this.gpsVerificationOptions(),
);
let planDestinationId: string | null = null;
if (dto.planId) {
planDestinationId = await this.visitsRepository.findPlanDestinationId(
dto.planId,
dto.customerId,
);
}
const now = DateTime.fromUnixMs(Date.now());
const created = await this.visitsRepository.create({
employeeId: employee.id,
customerId: dto.customerId,
attendanceId: openAttendance.id,
planId: dto.planId ?? null,
planDestinationId,
date: now.startOfDay(),
checkInAt: now,
checkInMethod: verified.method,
checkInLatitude: verified.latitude,
checkInLongitude: verified.longitude,
checkInPhotoUrl: verified.photoUrl,
checkInDistanceMeters: verified.distanceMeters,
userId,
});
await this.timelineActivitiesService.recordIfLocated({
employeeId: employee.id,
type: 'customer_check_in',
sourceType: 'visit',
sourceId: created.id,
latitude: verified.latitude,
longitude: verified.longitude,
recordedAt: now.value,
customerId: dto.customerId,
visitId: created.id,
});
return this.toItem(created);
}
async checkOut(
id: string,
dto: VisitCheckOutDto,
userId: string,
): Promise<VisitDto> {
const employee = await this.employeesService.requireByUserId(userId);
const visit = await this.visitsRepository.findById(id);
if (!visit) {
throw new NotFoundException('Visit not found');
}
if (visit.employeeId !== employee.id) {
throw new BadRequestException('Visit does not belong to this user');
}
if (visit.checkOutAt) {
throw new ConflictException('Visit is already checked out');
}
const customer = await this.customersService.findById(visit.customerId);
const radiusMeters =
await this.companySettingsService.requireCheckInRadiusMeters();
const payload = this.toPayload(dto);
const verified = verifyCustomerCheckIn(
{
code: customer.code,
nfcId: customer.nfcId,
latitude: customer.latitude,
longitude: customer.longitude,
},
payload,
radiusMeters,
this.gpsVerificationOptions(),
);
const updated = await this.visitsRepository.checkOut(id, {
checkOutAt: DateTime.fromUnixMs(Date.now()),
checkOutMethod: verified.method,
checkOutLatitude: verified.latitude,
checkOutLongitude: verified.longitude,
checkOutPhotoUrl: verified.photoUrl,
checkOutDistanceMeters: verified.distanceMeters,
userId,
});
await this.timelineActivitiesService.recordIfLocated({
employeeId: employee.id,
type: 'customer_check_out',
sourceType: 'visit',
sourceId: updated.id,
latitude: verified.latitude,
longitude: verified.longitude,
recordedAt: updated.checkOutAt?.value,
customerId: visit.customerId,
visitId: updated.id,
});
return this.toItem(updated);
}
async updateStatus(
id: string,
statusRaw: string,
userId: string,
): Promise<VisitDto> {
const status = Status.create(statusRaw);
const updated = await this.visitsRepository.updateStatus(
id,
status,
userId,
);
return this.toItem(updated);
}
async bulkUpdateStatus(
ids: string[],
statusRaw: string,
userId: string,
): Promise<{ updated: number }> {
const status = Status.create(statusRaw);
const updated = await this.visitsRepository.bulkUpdateStatus(
ids,
status,
userId,
);
return { updated };
}
async delete(id: string): Promise<void> {
await this.visitsRepository.delete(id);
}
async bulkDelete(ids: string[]): Promise<{ deleted: number }> {
const deleted = await this.visitsRepository.bulkDelete(ids);
return { deleted };
}
toItem(visit: Visit): VisitDto {
return {
id: visit.id,
employee: pickRelation(visit.employee, DEFAULT_RELATION_FIELDS)!,
customer: pickRelation(visit.customer, DEFAULT_RELATION_FIELDS)!,
attendanceId: visit.attendanceId,
planId: visit.planId,
planDestinationId: visit.planDestinationId,
date: visit.date.value,
checkInAt: visit.checkInAt.value,
checkInMethod: visit.checkInMethod,
checkInLatitude: visit.checkInLatitude,
checkInLongitude: visit.checkInLongitude,
checkInPhotoUrl: visit.checkInPhotoUrl,
checkInDistanceMeters: visit.checkInDistanceMeters,
checkOutAt: visit.checkOutAt?.value ?? null,
checkOutMethod: visit.checkOutMethod,
checkOutLatitude: visit.checkOutLatitude,
checkOutLongitude: visit.checkOutLongitude,
checkOutPhotoUrl: visit.checkOutPhotoUrl,
checkOutDistanceMeters: visit.checkOutDistanceMeters,
status: visit.status.value,
createdAt: visit.createdAt.value,
updatedAt: visit.updatedAt.value,
createdBy: pickUserRelation(
visit.createdByUser ?? { id: visit.createdBy, username: '' },
),
updatedBy: pickUserRelation(
visit.updatedByUser ?? { id: visit.updatedBy, username: '' },
),
};
}
private gpsVerificationOptions(): CheckInVerificationOptions {
return {
skipGpsValidation:
this.config.get<boolean>('SKIP_GPS_VALIDATION') === true,
};
}
private toPayload(dto: VisitCheckInDto | VisitCheckOutDto): CheckInPayload {
if (!isCheckInMethod(dto.method)) {
throw new BadRequestException('Invalid check-in method');
}
return {
method: dto.method,
nfcId: dto.nfcId,
qrCode: dto.qrCode,
latitude: dto.latitude,
longitude: dto.longitude,
photoUrl: dto.photoUrl,
};
}
}
void FIELD_VISIT_PRIVILEGE_KEY;
+2 -2
View File
@@ -146,7 +146,7 @@ export class PrivilegeDetailDto {
@ApiProperty({ format: 'uuid' })
privilegeKeyId!: string;
@ApiProperty({ example: 'SALES.INVOICE' })
@ApiProperty({ example: 'ADMIN.SALES.ACTIVITIES.INVOICE' })
keyCode!: string;
@ApiProperty({ example: 'Sales Invoice' })
@@ -197,7 +197,7 @@ export class PrivilegeKeyDto {
@ApiProperty({ format: 'uuid' })
id!: string;
@ApiProperty({ example: 'PRIVILEGES' })
@ApiProperty({ example: 'ADMIN.SETTINGS.USER.PRIVILEGES' })
code!: string;
@ApiProperty({ example: 'Privileges' })
@@ -17,14 +17,21 @@ describe('privilege-action', () => {
});
describe('privilege-key-code', () => {
it('accepts dotted uppercase module levels', () => {
expect(isValidPrivilegeKeyCode('PRIVILEGES')).toBe(true);
expect(isValidPrivilegeKeyCode('SALES.INVOICE')).toBe(true);
expect(isValidPrivilegeKeyCode('SALES.INVOICE.LINE')).toBe(true);
expect(assertPrivilegeKeyCode('USERS')).toBe('USERS');
it('accepts 3- and 4-part dotted uppercase codes', () => {
expect(isValidPrivilegeKeyCode('ADMIN.SETTINGS.USER.PRIVILEGES')).toBe(
true,
);
expect(isValidPrivilegeKeyCode('ADMIN.SALES.ACTIVITIES.INVOICE')).toBe(
true,
);
expect(isValidPrivilegeKeyCode('MOBILE.SALES.PLAN')).toBe(true);
expect(assertPrivilegeKeyCode('MOBILE.SALES.PLAN')).toBe(
'MOBILE.SALES.PLAN',
);
});
it('rejects invalid codes', () => {
expect(isValidPrivilegeKeyCode('PRIVILEGES')).toBe(false);
expect(isValidPrivilegeKeyCode('sales.invoice')).toBe(false);
expect(isValidPrivilegeKeyCode('SALES.')).toBe(false);
expect(isValidPrivilegeKeyCode('.SALES')).toBe(false);
@@ -0,0 +1,71 @@
import {
assertPrivilegeKeyCode,
isValidPrivilegeKeyCode,
parsePrivilegeKeyCode,
} from './privilege-key-code';
describe('privilege-key-code', () => {
describe('isValidPrivilegeKeyCode', () => {
it('accepts 3-part keys', () => {
expect(isValidPrivilegeKeyCode('MOBILE.SALES.PLAN')).toBe(true);
expect(isValidPrivilegeKeyCode('ADMIN.SALES.REPORT')).toBe(true);
});
it('accepts 4-part keys', () => {
expect(isValidPrivilegeKeyCode('MOBILE.SALES.PLAN.ATTENDANCE')).toBe(
true,
);
expect(isValidPrivilegeKeyCode('ADMIN.SALES.ACTIVITIES.PLAN')).toBe(true);
});
it('rejects fewer than 3 segments', () => {
expect(isValidPrivilegeKeyCode('PRIVILEGES')).toBe(false);
expect(isValidPrivilegeKeyCode('SALES.PLAN')).toBe(false);
});
it('rejects more than 4 segments', () => {
expect(isValidPrivilegeKeyCode('A.B.C.D.E')).toBe(false);
});
it('rejects invalid characters', () => {
expect(isValidPrivilegeKeyCode('mobile.sales.plan')).toBe(false);
expect(isValidPrivilegeKeyCode('MOBILE..PLAN')).toBe(false);
});
});
describe('assertPrivilegeKeyCode', () => {
it('returns the code when valid', () => {
expect(assertPrivilegeKeyCode('MOBILE.SALES.PLAN')).toBe(
'MOBILE.SALES.PLAN',
);
});
it('throws when invalid', () => {
expect(() => assertPrivilegeKeyCode('PRIVILEGES')).toThrow(TypeError);
});
});
describe('parsePrivilegeKeyCode', () => {
it('parses a 3-part key with null submodule', () => {
expect(parsePrivilegeKeyCode('MOBILE.SALES.PLAN')).toEqual({
group: 'MOBILE',
parent: 'SALES',
module: 'PLAN',
submodule: null,
});
});
it('parses a 4-part key', () => {
expect(parsePrivilegeKeyCode('MOBILE.SALES.PLAN.ATTENDANCE')).toEqual({
group: 'MOBILE',
parent: 'SALES',
module: 'PLAN',
submodule: 'ATTENDANCE',
});
});
it('throws for invalid codes', () => {
expect(() => parsePrivilegeKeyCode('PRIVILEGES')).toThrow(TypeError);
});
});
});
+20 -2
View File
@@ -1,6 +1,13 @@
/** Dotted uppercase module levels: MODULE / MODULE.RESOURCE / MODULE.RESOURCE.SUB */
/** Dotted uppercase: Group.Parent.Module or Group.Parent.Module.Submodule */
export const PRIVILEGE_KEY_CODE_PATTERN =
/^[A-Z][A-Z0-9_]*(\.[A-Z][A-Z0-9_]*)*$/;
/^[A-Z][A-Z0-9_]*\.[A-Z][A-Z0-9_]*\.[A-Z][A-Z0-9_]*(?:\.[A-Z][A-Z0-9_]*)?$/;
export type ParsedPrivilegeKeyCode = {
readonly group: string;
readonly parent: string;
readonly module: string;
readonly submodule: string | null;
};
export function isValidPrivilegeKeyCode(code: string): boolean {
return typeof code === 'string' && PRIVILEGE_KEY_CODE_PATTERN.test(code);
@@ -12,3 +19,14 @@ export function assertPrivilegeKeyCode(code: string): string {
}
return code;
}
export function parsePrivilegeKeyCode(code: string): ParsedPrivilegeKeyCode {
assertPrivilegeKeyCode(code);
const parts = code.split('.');
if (parts.length === 3) {
const [group, parent, module] = parts;
return { group, parent, module, submodule: null };
}
const [group, parent, module, submodule] = parts;
return { group, parent, module, submodule };
}
@@ -28,7 +28,7 @@ export class PrivilegeKeysController {
@Get()
@Pagination()
@RequirePrivilege('PRIVILEGES', 'view')
@RequirePrivilege('ADMIN.SETTINGS.USER.PRIVILEGES', 'view')
@ApiOperation({ summary: 'List privilege keys catalog' })
@ApiOkResponse({
schema: {
@@ -30,7 +30,7 @@ export class PrivilegesReadController {
@Get()
@Pagination()
@RequirePrivilege('PRIVILEGES', 'view')
@RequirePrivilege('ADMIN.SETTINGS.USER.PRIVILEGES', 'view')
@ApiOperation({ summary: 'List privileges' })
@ApiOkResponse({
schema: {
@@ -52,7 +52,7 @@ export class PrivilegesReadController {
}
@Get(':id')
@RequirePrivilege('PRIVILEGES', 'view')
@RequirePrivilege('ADMIN.SETTINGS.USER.PRIVILEGES', 'view')
@ApiOperation({ summary: 'Get privilege detail with matrix' })
@ApiOkResponse({ type: PrivilegeDetailResponseDto })
@ApiNotFoundResponse()
@@ -46,7 +46,7 @@ export class PrivilegesWriteController {
constructor(private readonly privilegesService: PrivilegesService) {}
@Post('import')
@RequirePrivilege('PRIVILEGES', 'import')
@RequirePrivilege('ADMIN.SETTINGS.USER.PRIVILEGES', 'import')
@UseInterceptors(
FileInterceptor('file', {
limits: { fileSize: 1_048_576 },
@@ -90,7 +90,7 @@ export class PrivilegesWriteController {
@Post('bulk-delete')
@HttpCode(200)
@RequirePrivilege('PRIVILEGES', 'delete')
@RequirePrivilege('ADMIN.SETTINGS.USER.PRIVILEGES', 'delete')
@ApiOperation({ summary: 'Bulk delete privileges' })
@ApiOkResponse({
schema: { properties: { deleted: { type: 'number' } } },
@@ -103,7 +103,7 @@ export class PrivilegesWriteController {
@Post('bulk-status')
@HttpCode(200)
@RequirePrivilege('PRIVILEGES', 'update')
@RequirePrivilege('ADMIN.SETTINGS.USER.PRIVILEGES', 'update')
@ApiOperation({ summary: 'Bulk update privilege status' })
@ApiOkResponse({
schema: { properties: { updated: { type: 'number' } } },
@@ -118,7 +118,7 @@ export class PrivilegesWriteController {
}
@Post()
@RequirePrivilege('PRIVILEGES', 'create')
@RequirePrivilege('ADMIN.SETTINGS.USER.PRIVILEGES', 'create')
@ApiOperation({ summary: 'Create privilege' })
@ApiCreatedResponse({ type: PrivilegeDetailResponseDto })
@ApiUnauthorizedResponse()
@@ -137,7 +137,7 @@ export class PrivilegesWriteController {
}
@Patch(':id/status')
@RequirePrivilege('PRIVILEGES', 'update')
@RequirePrivilege('ADMIN.SETTINGS.USER.PRIVILEGES', 'update')
@ApiOperation({ summary: 'Update privilege status' })
@ApiOkResponse({ type: PrivilegeDto })
@ApiNotFoundResponse()
@@ -152,7 +152,7 @@ export class PrivilegesWriteController {
}
@Patch(':id')
@RequirePrivilege('PRIVILEGES', 'update')
@RequirePrivilege('ADMIN.SETTINGS.USER.PRIVILEGES', 'update')
@ApiOperation({ summary: 'Update privilege (not status)' })
@ApiOkResponse({ type: PrivilegeDetailResponseDto })
@ApiNotFoundResponse()
@@ -173,7 +173,7 @@ export class PrivilegesWriteController {
@Delete(':id')
@HttpCode(204)
@RequirePrivilege('PRIVILEGES', 'delete')
@RequirePrivilege('ADMIN.SETTINGS.USER.PRIVILEGES', 'delete')
@ApiOperation({ summary: 'Delete privilege' })
@ApiNoContentResponse()
@ApiNotFoundResponse()
@@ -389,6 +389,39 @@ export class PrivilegesRepository {
return row?.value === true;
}
async checkAnyPermission(
userId: string,
keyCodes: readonly string[],
action: PrivilegeAction,
): Promise<boolean> {
if (keyCodes.length === 0) {
return false;
}
const [row] = await this.db
.select({ value: privilegeDetails.value })
.from(users)
.innerJoin(privileges, eq(users.privilegeId, privileges.id))
.innerJoin(
privilegeDetails,
eq(privilegeDetails.privilegeId, privileges.id),
)
.innerJoin(
privilegeKeys,
eq(privilegeDetails.privilegeKeyId, privilegeKeys.id),
)
.where(
and(
eq(users.id, userId),
eq(privileges.status, 'active'),
inArray(privilegeKeys.code, [...keyCodes]),
eq(privilegeDetails.action, action),
eq(privilegeDetails.value, true),
),
)
.limit(1);
return row?.value === true;
}
async getPermissionsMap(
privilegeId: string,
): Promise<Record<string, Record<PrivilegeAction, boolean>>> {
@@ -23,6 +23,7 @@ describe('PrivilegesService', () => {
| 'listKeys'
| 'findKeyById'
| 'checkPermission'
| 'checkAnyPermission'
| 'getPermissionsMap'
>
>;
@@ -56,6 +57,7 @@ describe('PrivilegesService', () => {
listKeys: jest.fn(),
findKeyById: jest.fn(),
checkPermission: jest.fn(),
checkAnyPermission: jest.fn(),
getPermissionsMap: jest.fn(),
};
@@ -140,7 +142,40 @@ describe('PrivilegesService', () => {
it('checkPermission delegates', async () => {
repository.checkPermission.mockResolvedValue(true);
await expect(
service.checkPermission('user-1', 'PRIVILEGES', 'view'),
service.checkPermission(
'user-1',
'ADMIN.SETTINGS.USER.PRIVILEGES',
'view',
),
).resolves.toBe(true);
});
it('checkAnyPermission delegates for multiple keys', async () => {
repository.checkAnyPermission.mockResolvedValue(true);
await expect(
service.checkAnyPermission(
'user-1',
['ADMIN.SALES.ACTIVITIES.PLAN', 'MOBILE.SALES.PLAN'],
'view',
),
).resolves.toBe(true);
expect(repository.checkAnyPermission).toHaveBeenCalledWith(
'user-1',
['ADMIN.SALES.ACTIVITIES.PLAN', 'MOBILE.SALES.PLAN'],
'view',
);
});
it('checkAnyPermission uses checkPermission for a single key', async () => {
repository.checkPermission.mockResolvedValue(true);
await expect(
service.checkAnyPermission('user-1', ['MOBILE.SALES.PLAN'], 'view'),
).resolves.toBe(true);
expect(repository.checkPermission).toHaveBeenCalledWith(
'user-1',
'MOBILE.SALES.PLAN',
'view',
);
expect(repository.checkAnyPermission).not.toHaveBeenCalled();
});
});
@@ -262,6 +262,24 @@ export class PrivilegesService {
return this.privilegesRepository.checkPermission(userId, keyCode, action);
}
async checkAnyPermission(
userId: string,
keyCodes: readonly string[],
action: PrivilegeAction,
): Promise<boolean> {
if (keyCodes.length === 0) {
return false;
}
if (keyCodes.length === 1) {
return this.checkPermission(userId, keyCodes[0], action);
}
return this.privilegesRepository.checkAnyPermission(
userId,
keyCodes,
action,
);
}
async getPermissionsMap(
privilegeId: string,
): Promise<Record<string, Record<PrivilegeAction, boolean>>> {

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