Multi-tenant SaaS delivery management platform for logistics companies.
DropFlow is a web-based platform that lets logistics companies manage their entire delivery operation β from client and order management through route planning, driver dispatch, and delivery validation. Each company runs in a fully isolated tenant, with role-based access control separating platform administrators, operations managers, and drivers. The application is built as a React + TypeScript single-page front-end communicating with an ASP.NET Core REST API backed by PostgreSQL.
- Multi-tenant isolation β every data record is scoped to a tenant via EF Core global query filters; TenantId is resolved from JWT claims, never from client input
- Authentication β JWT-based login with ASP.NET Identity (password complexity rules, account lockout after 5 failed attempts)
- Role-based authorization β three roles (Admin, Manager, Livreur) enforced at the API and UI layer
- User management β invite users by email, assign roles, activate/deactivate, soft-delete and restore
- Platform administration β DropFlow super-admin can create, activate/deactivate, and manage all tenant companies and their users
- Client management β full CRUD with multiple delivery addresses per client; default address selection; address geocoding via Google Maps
- Delivery management β create, edit, duplicate, bulk status update, bulk soft-delete; Standard and Urgent delivery types; sequential reference numbers (DL-YYYYMMDD-NNNN)
- Delivery kanban / list view β filterable and sortable paginated list; status pipeline (ToBePlanned β Confirmed β InProgress β Delivered / Canceled)
- Route management β create route sheets, assign deliveries, manage driver teams (main driver + helpers), confirm/start/complete/cancel lifecycle
- Route optimization β Google Directions API integration for waypoint ordering; manual drag-and-drop reordering with metric recalculation
- Route sheet PDF β downloadable PDF generated with QuestPDF
- Driver app API β dedicated endpoints for the driver mobile experience: today's route, delivery detail (PII-limited view), delivery validation with signature and photo upload, route start/complete
- Delivery validation β signature capture, photo upload (base64), client-absent handling; files stored on the server filesystem
- Depot management β multi-depot support per tenant; default depot selection for route starting points
- Company settings β update company info, legal info, and logo per tenant
- Dashboard β KPI cards (unplanned deliveries, today's deliveries, monthly revenue, active routes), today's delivery list, at-risk delivery list, revenue/status/store charts
- Audit logging β every significant action is recorded with tenant, user, entity, and severity
- Address autocomplete β Google Places API integration for address input fields
- Time slot management β configurable delivery time windows
- Store management β CRUD for warehouse/store origins of deliveries
- Vehicle management β CRUD for delivery fleet
- Driver management β CRUD linked to Identity users; availability checking
- Profile management β users can update their profile, change password, and set UI preferences
- Health check β
/healthendpoint backed by EF Core database check - Request logging middleware β structured request/response logging
- Global exception handling β consistent JSON error responses; stack traces hidden in production
- Route optimization algorithm β the Google Directions API call and waypoint reordering work; the
RecalculateMetricsendpoint is stubbed out (returns 200 with no data) - Dashboard stats β KPI data is live; chart data makes multiple round-trips instead of a single optimized query (tracked as a known issue)
- Driver mobile experience β API endpoints are complete; the PWA front-end is not yet started (see Roadmap)
- PWA mobile driver application
- Invoice PDF generation
- Invoice email delivery
- Stock / inventory module
- Advanced reporting and analytics
- SMS notifications (Twilio)
DropFlow follows a layered architecture with a clean separation of concerns across six projects.
DropFlow.sln
βββ backend/
β βββ DropFlow.Domain # Entities, enums, interfaces, constants β no dependencies
β βββ DropFlow.Application # Service layer, DTOs, FluentValidation β depends on Domain
β βββ DropFlow.Infrastructure # EF Core, Identity, external services β depends on Application
β βββ DropFlow.Api # ASP.NET Core Web API β depends on Application + Infrastructure
βββ shared/
β βββ DropFlow.Shared # DTOs shared between backend projects
βββ frontend/
β βββ DropFlow.Web # React + TypeScript SPA (Vite) β consumes the API over HTTP
βββ mobile/
βββ DropFlow.Mobile # .NET MAUI driver app
The DropFlow.Api and the DropFlow.Web front-end are separate processes. The React SPA communicates with the API exclusively over HTTP (Axios). There is no shared in-process state between them.
React SPA β (HTTP) β Api β Application β Domain
β
Infrastructure β Application
- No MediatR / CQRS β services are injected directly via interfaces
ResponseResult<T>β all service methods return a typed result wrapper instead of throwing exceptions to the UI- Typed API modules β the React app groups API calls in per-domain modules under
src/api/; an Axios interceptor handles JWT attachment and 401 token refresh - Multi-step wizards β state is held in a centralized Zustand store, not individual component state
- Cross-tab sync β a
BroadcastChannelpropagates delivery change events across browser tabs
| Layer | Technology |
|---|---|
| Runtime | .NET 9 (SDK 10.x required) |
| API framework | ASP.NET Core 9 |
| Frontend framework | React 18 + TypeScript (Vite) |
| UI components / styling | shadcn/ui + Tailwind CSS v4 |
| Frontend data layer | TanStack Query + Zustand + Axios |
| ORM | Entity Framework Core 9 |
| Database | PostgreSQL (Neon) |
| Identity | ASP.NET Core Identity |
| Authentication | JWT Bearer tokens |
| Client-side auth storage | localStorage (Zustand persist) |
| PDF generation | QuestPDF 2025.x |
| Validation | FluentValidation 12.x |
| Mapping display strings | Humanizer.Core 3.x |
| External geocoding | Google Maps Geocoding API |
| External address search | Google Places API |
| External route optimization | Google Directions API |
| SMTP (Gmail or any SMTP provider) | |
| Logging | Microsoft.Extensions.Logging + Serilog.AspNetCore |
| API docs | Swagger / Swashbuckle (development only) |
| Seed data generation | Bogus 35.x |
- .NET 10 SDK (targets net9.0; SDK 10 required by
global.json) - PostgreSQL (a Neon branch or a local instance)
- Node.js 18+ and npm (for the React front-end)
- A Google Cloud project with the following APIs enabled:
- Maps JavaScript API
- Geocoding API
- Directions API
- Places API (New)
- An SMTP account for outbound email (Gmail app password, SendGrid, etc.)
The application uses appsettings.json for non-secret configuration and .NET User Secrets for all sensitive values. Never commit real credentials to appsettings.json.
Initialize user secrets for the API project:
cd src/DropFlow.Api
dotnet user-secrets init
dotnet user-secrets set "JwtSettings:SecretKey" "your-256-bit-secret-key-here"
dotnet user-secrets set "SuperAdmin:Password" "your-admin-password"
dotnet user-secrets set "EmailSettings:Smtp:Password" "your-smtp-app-password"
dotnet user-secrets set "Google:MapsApiKey" "your-google-maps-api-key"See the Configuration Reference section below for all available keys.
Apply the EF Core migration to create the database schema:
cd src/DropFlow.Api
dotnet ef database update --project ../DropFlow.InfrastructureThe application automatically seeds initial data (roles, DropFlow admin user, and sample tenant data) on first startup via InitializeDatabaseAsync().
Run two processes: the API and the React front-end.
Terminal 1 β API:
cd backend/DropFlow.Api
dotnet run
# Listens on https://localhost:7001Terminal 2 β React front-end:
cd frontend/DropFlow.Web
npm install
npm run dev
# Serves on http://localhost:3000 (must match AllowedOrigins for CORS)Swagger UI is available at https://localhost:7001/swagger in the Development environment.
All keys below are read from appsettings.json or overridden by environment variables / user secrets. Keys marked Required must be set before the application will start or function correctly.
| Key | Description | Required | Secret |
|---|---|---|---|
ConnectionStrings:DefaultConnection |
PostgreSQL connection string (Npgsql) | Yes | No |
JwtSettings:SecretKey |
HMAC-SHA256 signing key for JWT tokens (min. 32 characters) | Yes | Yes |
JwtSettings:Issuer |
JWT issuer claim value | Yes | No |
JwtSettings:Audience |
JWT audience claim value | Yes | No |
JwtSettings:ExpirationHours |
Token lifetime in hours (default: 8) | Yes | No |
SuperAdmin:Email |
Email address for the initial DropFlow platform admin | Yes | No |
SuperAdmin:Password |
Password for the initial platform admin | Yes | Yes |
AppUrl |
Base URL of the API (used in email links) | Yes | No |
BlazorClientUrl |
Base URL of the web front-end (legacy config key name) | Yes | No |
AllowedOrigins |
JSON array of origins permitted by CORS (development) | Yes | No |
ProductionUrl |
Single origin permitted by CORS in production | No | No |
FileStorage:BasePath |
Absolute path on the server where uploaded files are stored | Yes | No |
EmailSettings:Provider |
Email provider type (currently only Smtp is implemented) |
Yes | No |
EmailSettings:Smtp:Host |
SMTP server hostname | Yes | No |
EmailSettings:Smtp:Port |
SMTP server port (typically 587 for TLS) | Yes | No |
EmailSettings:Smtp:EnableSsl |
Enable STARTTLS (true/false) |
Yes | No |
EmailSettings:Smtp:Username |
SMTP authentication username | Yes | No |
EmailSettings:Smtp:Password |
SMTP authentication password or app password | Yes | Yes |
EmailSettings:Smtp:FromEmail |
Sender email address shown to recipients | Yes | No |
EmailSettings:Smtp:FromName |
Sender display name shown to recipients | Yes | No |
EmailSettings:DefaultSubjects:UserInvitation |
Default subject for invitation emails | No | No |
EmailSettings:DefaultSubjects:PasswordReset |
Default subject for password reset emails | No | No |
EmailSettings:DefaultSubjects:DeliveryNote |
Default subject for delivery note emails | No | No |
EmailSettings:DefaultSubjects:Invoice |
Default subject for invoice emails | No | No |
Google:MapsApiKey |
Google Maps API key (Geocoding, Directions, Places APIs enabled) | Yes | Yes |
Logging:LogLevel:Default |
Minimum log level for all categories | No | No |
Logging:LogLevel:Microsoft.AspNetCore |
Log level for ASP.NET Core internals | No | No |
Logging:LogLevel:Microsoft.EntityFrameworkCore |
Log level for EF Core internals | No | No |
Logging:LogLevel:DropFlow |
Log level for application code | No | No |
Every data entity that belongs to a tenant implements ITenantEntity:
public interface ITenantEntity
{
int TenantId { get; set; }
}The DropFlow platform admin uses the reserved TenantId = 0 and can see all tenant data.
Query filtering β ApplicationDbContext.OnModelCreating registers a global query filter for every tenant entity. The filter calls GetCurrentTenantId() at query time, which reads the TenantId claim from the current HTTP request's JWT:
modelBuilder.Entity<Delivery>().HasQueryFilter(d =>
GetCurrentTenantId() == TenantIds.DropFlowAdmin ||
d.TenantId == GetCurrentTenantId()
);This filter is automatically applied to all LINQ queries on those entities β including eager loads via Include().
Write isolation β SaveChangesAsync calls ApplyTenantId() before writing, which stamps TenantId on all newly added entities. Client-supplied TenantId fields in DTOs are ignored entirely.
Claim resolution β TenantService.GetTenantId() extracts the tenant from the authenticated user's JWT claims. Services call this method to scope business logic, aggregate queries, and audit records.
Important:
DbContext.FindAsync()bypasses global query filters in EF Core. All lookups by primary key on tenant-filtered entities must useFirstOrDefaultAsync(e => e.Id == id)to maintain isolation. See Known Issues for tracked cases.
- The user navigates to
/loginin the web app - If the account exists in multiple tenants, the app calls
GET /api/auth/tenants?email=to list options - The user selects a tenant and submits credentials via
POST /api/auth/login - The API returns a signed JWT containing:
UserId,Email,FullName,Role,TenantId,IsActive,TenantName - The web app stores the JWT in
localStorage(via the Zustand auth store) - Subsequent API calls attach the token as
Authorization: Bearer <token>
| Role | Constant | Access |
|---|---|---|
Admin |
Roles.Admin |
Full access to all tenant data; can manage users, settings, routes, deliveries, clients, vehicles, stores, drivers |
Manager |
Roles.Manager |
Same operational access as Admin within the tenant; cannot manage platform-level settings |
Livreur |
Roles.Livreur |
Read access to their own assigned routes and deliveries via the driver API; can validate deliveries |
| Policy | Applies to |
|---|---|
RequireAdmin |
Admin role only |
RequireManager |
Manager or Admin role |
ActiveUser |
Any authenticated user with IsActive = true claim |
ActiveManager |
Manager or Admin with IsActive = true |
SameTenant |
Custom handler validating user TenantId matches route {tenantId} parameter |
Managers and Admins invite users by email via POST /api/usermanagement/invite. The system sends an email containing a time-limited token (72 hours). The recipient clicks the link, lands on /accept-invitation, sets a password, and is immediately logged in with a JWT.
Base URL: https://localhost:7001 (development)
All endpoints require Authorization: Bearer <token> unless marked [Public].
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /register |
Public | Register a new tenant (creates company + Manager user) |
| POST | /login |
Public | Authenticate and receive JWT |
| POST | /accept-invitation |
Public | Complete invitation flow and set password |
| POST | /forgot-password |
Public | Request password reset email |
| POST | /reset-password |
Public | Apply password reset token |
| GET | /tenants?email= |
Public | List tenants for an email address (for multi-tenant login picker) |
| Method | Path | Description |
|---|---|---|
| GET | /tenants |
List all tenants |
| GET | /tenants/{tenantId} |
Tenant detail with users and stats |
| POST | /tenants/{tenantId}/activate |
Activate a tenant |
| POST | /tenants/{tenantId}/deactivate |
Deactivate a tenant |
| PUT | /tenants/{tenantId}/plan |
Update tenant plan |
| DELETE | /tenants/{tenantId} |
Soft-delete a tenant |
| GET | /tenants/{tenantId}/users |
List all users in a tenant |
| POST | /tenants/{tenantId}/users/{userId}/activate |
Activate a user |
| POST | /tenants/{tenantId}/users/{userId}/deactivate |
Deactivate a user |
| GET | /stats |
Global platform statistics |
| GET | /audit |
Audit log with filters (tenantId, userId, action, severity, date range, pagination) |
| GET | /users |
All users across all tenants (paginated, filtered) |
| GET | /users/stats |
Global user statistics |
| POST | /users/{userId}/activate |
Activate user globally |
| POST | /users/{userId}/deactivate |
Deactivate user globally |
| Method | Path | Description |
|---|---|---|
| GET | / |
Paginated client list with filters |
| GET | /search?query= |
Client autocomplete search |
| GET | /{id} |
Client detail with addresses |
| GET | /{id}/addresses |
Client address list |
| GET | /{id}/deliveries |
Client delivery history |
| POST | / |
Create client |
| POST | /{id}/addresses |
Add address to client |
| PUT | /{id} |
Update client |
| PUT | /{clientId}/addresses/{addressId} |
Update address |
| PUT | /{clientId}/addresses/{addressId}/set-default |
Set default address |
| DELETE | /{id} |
Soft-delete client |
| DELETE | /{clientId}/addresses/{addressId} |
Delete address |
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | / |
Any | Paginated delivery list with filters |
| GET | /{id} |
Any | Delivery detail |
| GET | /stats |
Any | Delivery aggregate stats |
| GET | /unassigned?date= |
Manager, Admin | Deliveries not yet in a route for a date |
| GET | /available-for-route?date=¤tRouteId= |
Manager, Admin | Deliveries eligible for route assignment |
| POST | / |
Any | Create delivery |
| POST | /{id}/duplicate |
Any | Duplicate delivery |
| POST | /batch/status |
Any | Bulk status update |
| POST | /batch/delete |
Manager, Admin | Bulk soft-delete |
| PUT | /{id} |
Any | Update delivery |
| PATCH | /{id}/status |
Any | Update single delivery status |
| DELETE | /{id} |
Manager, Admin | Delete delivery |
| Method | Path | Description |
|---|---|---|
| GET | / |
Paginated route list with filters |
| GET | /{id} |
Route detail with team and deliveries |
| GET | /{id}/download-sheet |
Download route sheet PDF |
| POST | / |
Create route |
| POST | /optimize |
Optimize delivery order via Google Directions |
| POST | /recalculate-path |
Recalculate metrics after manual reorder |
| POST | /{id}/teamMember |
Add driver to route team |
| POST | /{id}/deliveries/{deliveryId} |
Assign delivery to route |
| POST | /{id}/confirm |
Confirm route (locks deliveries) |
| POST | /{id}/start |
Start route execution |
| POST | /{id}/complete |
Mark route complete |
| POST | /{id}/cancel |
Cancel route |
| PUT | /{id} |
Update route |
| PUT | /{id}/sequence |
Update delivery order in route |
| DELETE | /{id} |
Delete route |
| DELETE | /{id}/team/{driverId} |
Remove driver from team |
| DELETE | /{id}/deliveries/{deliveryId} |
Remove delivery from route |
| Method | Path | Description |
|---|---|---|
| GET | /route/today |
Today's assigned route for the authenticated driver |
| GET | /deliveries/{id} |
Delivery detail (PII-limited: no internal notes or pricing) |
| POST | /deliveries/{id}/validate |
Validate delivery with signature + optional photo |
| POST | /route/{id}/start |
Start a route |
| POST | /route/{id}/complete |
Complete a route |
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /current |
Any | Current tenant info |
| PUT | /company-info |
Manager, Admin | Update company details |
| PUT | /legal-info |
Manager, Admin | Update legal details |
| PUT | /logo |
Manager, Admin | Upload company logo (base64) |
| DELETE | /logo |
Manager, Admin | Remove company logo |
| GET | /depots/all |
Any | All active depots (for dropdowns) |
| GET | /depots |
Any | Paginated depot list |
| GET | /depots/{id} |
Any | Depot detail |
| POST | /depots |
Manager, Admin | Create depot |
| PUT | /depots/{id} |
Manager, Admin | Update depot |
| DELETE | /depots/{id} |
Admin | Delete depot |
| POST | /depots/{id}/set-default |
Manager, Admin | Set default depot |
| POST | /depots/{id}/toggle-status |
Manager, Admin | Activate / deactivate depot |
| Method | Path | Description |
|---|---|---|
| GET | /users |
List tenant users |
| POST | /invite |
Invite user by email |
| POST | /{userId}/activate |
Activate user |
| POST | /{userId}/deactivate |
Deactivate user |
| POST | /{userId}/restore |
Restore soft-deleted user |
| PUT | /users/{userId}/role |
Change user role |
| DELETE | /users/{userId} |
Soft-delete user |
| Controller | Base | Auth | Description |
|---|---|---|---|
| Stores | /api/stores |
Manager, Admin | Store CRUD with filters |
| Vehicles | /api/vehicles |
Manager, Admin | Vehicle CRUD with filters |
| Drivers | /api/drivers |
Manager, Admin | Driver CRUD, availability check |
| TimeSlots | /api/timeslots |
Manager, Admin | Delivery time window CRUD |
| Profile | /api/profile |
Any | Get/update profile, change password, preferences |
| Dashboard | /api/dashboard |
Manager, Admin | KPI stats, charts, notifications, events |
| Files | /api/files/{*relativePath} |
Any | Serve uploaded delivery files (signatures, photos) |
| Geocoding | /api/geocoding |
Public | Google Maps geocoding and Places autocomplete (dev/test endpoints) |
Note:
GeocodingControlleris marked[AllowAnonymous]and is intended for development testing only. It should be protected or removed before production deployment.
The following issues were identified during code review and are tracked for remediation. Issues are ordered by severity.
-
Cross-tenant delete via
FindAsyncβDeliveryService.DeleteDeliveryAsyncandUpdateStatusAsyncusecontext.Deliveries.FindAsync(id), which bypasses EF Core global query filters. An authenticated user can delete or update the status of any tenant's delivery by ID. Fix: replace withFirstOrDefaultAsync(d => d.Id == id). -
Client-supplied FK IDs not validated β
CreateDeliveryandUpdateDeliveryacceptClientIdandClientAddressIdfrom the request body and write them directly as foreign keys without verifying they belong to the current tenant. Fix: validate ownership via a filteredAnyAsynccall before accepting the ID. -
Path traversal in file serving β
FileStorageService.GetFileAsyncconstructs an absolute path withPath.Combine(_basePath, relativePath)without verifying the result stays within_basePath. Fix: check that the resolved path starts with_basePathbefore reading. -
Real credentials in
appsettings.jsonβ the Gmail SMTP password and Google Maps API key are committed as live values. Immediate action required: revoke both credentials, rotate them, and move them to user secrets / environment variables. -
Reference number race condition β
DeliveryReferenceServiceuses a random number, checks for existence, and returns β with no database lock between the check and the subsequent insert. Concurrent requests can generate duplicate reference numbers. Fix: add aUNIQUEindex on(TenantId, Reference)and use an atomic sequential counter. -
Route reference prefix bug β
RouteReferenceServicegenerates the initial reference with the prefixFR-instead ofRT-. The fallback retry path usesRT-correctly, making the initial path always wrong. Fix: change"FR-{dateString}-{sequentialNumber:D3}"to"RT-{dateString}-{sequentialNumber:D3}".
-
DashboardServicecapturesTenantIdat construction βprivate readonly int _tenantId = tenantService.GetTenantId()runs at DI resolution time and throws if resolved outside an HTTP context. -
GetStatsAsyncloads all deliveries into memory β the delivery stats method callsToListAsync()on all tenant deliveries and aggregates in C#. Should use server-sideGroupBy/Sum. -
Google Maps API key appears in application logs β
GeocodingServicelogs the full request URL including the API key atInformationlevel. -
TenantService.GetCurrentUser()is synchronous β blocks a thread pool thread on a database call inside an async pipeline. -
NullReferenceExceptioninGetUnassignedDeliveriesAsyncβd.UrgentDriver.User.FullNameis accessed without a null guard in the projection (line 221); the urgent driver is optional. -
Failed login returns HTTP 200 β
AuthController.Loginalways returnsOk(result)regardless of authentication outcome.
-
Missing
AsNoTracking()on the main delivery list query and several other read-only list queries. -
No
pageSizemaximum enforcement on admin audit log and user list endpoints; callers can request unlimited rows. -
DateTime.Todayused for UTC date comparisons inDashboardService; mismatches by the server UTC offset during cross-midnight periods. -
No rate limiting on
/api/auth/login,/api/auth/forgot-password, or/api/auth/register. -
No server-side token revocation β deactivating a user does not invalidate their existing JWT; they retain access for up to 8 hours.
-
Audit log
Changesfield stores PII β emails and roles appear in theChangesJSON blob of audit log entries. -
GeocodingControlleris unauthenticated β the geocoding test endpoints are[AllowAnonymous]and should be restricted or removed in production. -
No geocoding result cache β every delivery creation triggers a live Google Maps Geocoding API call even for previously geocoded addresses.
The following features are specified but not yet implemented:
- PWA mobile driver application β native mobile experience for the
Livreurrole; the server-side API (/api/driver/...) is ready - Invoice PDF generation β generate printable invoices with QuestPDF
- Invoice email delivery β send invoices to clients automatically
- Stock / inventory module β track goods associated with deliveries
- Advanced reporting and analytics β historical data, trend charts, export
- SMS notifications β delivery status updates via Twilio
- Push notifications β real-time driver alerts
- Token revocation β immediate effect when a user is deactivated
- Fork the repository and create a feature branch from
main - Follow the commit message convention:
[Module] Action: short description- Example:
[Deliveries] Fix: cross-tenant delete via FindAsync - Example:
[Routes] Add: recalculate metrics endpoint
- Example:
- Business logic belongs in the Application layer, not in controllers or UI components
- New entities must implement
ITenantEntityand have a corresponding global query filter registered inApplicationDbContext.OnModelCreating - New list endpoints must support
PageNumber+PageSizepagination - All service methods must return
ResponseResult<T>β do not throw exceptions to callers - Run existing tests before opening a pull request
Proprietary β all rights reserved.