# Offline Maps Management

Production-ready full-stack scaffold for managing large offline map ZIP files with a NestJS backend, PostgreSQL/Prisma, Redis caching, BullMQ cleanup jobs, AWS S3 multipart uploads, and a React/Vite admin dashboard.

## Structure

```text
offline-maps-management/
  backend/            NestJS + Prisma API
  admin-dashboard/    React + TypeScript + Vite admin UI
  docker-compose.yml  PostgreSQL, Redis, backend, dashboard
```

## Backend Setup

```bash
cd offline-maps-management/backend
cp .env.example .env
npm install
npm run prisma:generate
npm run prisma:migrate
npm run seed
npm run start:dev
```

The API runs on `http://localhost:3000`. Swagger/OpenAPI docs are available at `http://localhost:3000/docs`.

Required environment variables are documented in `backend/.env.example`. Set real values for PostgreSQL, Redis, JWT secrets, and AWS S3 before running uploads.

## Admin Dashboard Setup

```bash
cd offline-maps-management/admin-dashboard
cp .env.example .env
npm install
npm run dev
```

The dashboard runs on `http://localhost:5173`.

## Docker Setup

Create `backend/.env` and `admin-dashboard/.env`, then run:

```bash
cd offline-maps-management
docker compose up --build
```

Run migrations and seed after the database is ready:

```bash
docker compose exec backend npx prisma migrate deploy
docker compose exec backend npm run seed
```

## Multipart Upload Flow

1. The dashboard calls `POST /offline-maps/initiate-upload` with country, state, file name, size, and ZIP MIME type.
2. The backend creates an `OfflineMap` record with `UPLOADING`, creates an S3 multipart upload, and returns `offlineMapId`, `uploadId`, and `s3Key`.
3. The dashboard splits the file into 10 MB parts, calls `POST /offline-maps/presigned-part` for each part, and uploads each chunk directly to S3.
4. The dashboard calls `POST /offline-maps/complete-upload` with the uploaded part numbers and ETags.
5. The backend completes S3 multipart upload, marks the map `COMPLETED`, activates it, stores the S3 URL, and clears map URL cache.

Replacement uses `PATCH /offline-maps/:id/file` to initiate and `POST /offline-maps/:id/file/complete-upload` to complete. The old S3 file is queued for deletion only after the new upload and DB update succeed.

## S3 Bucket CORS

Because the dashboard uploads ZIP parts directly to presigned S3 URLs from the browser, the S3 bucket must allow the dashboard origin to send `PUT` requests and must expose the `ETag` header. Without this, the browser will show a generic network error even when the presigned URL is valid.

Use this CORS policy for local development, replacing or adding production origins as needed:

```json
[
  {
    "AllowedHeaders": ["*"],
    "AllowedMethods": ["GET", "HEAD", "PUT", "POST"],
    "AllowedOrigins": ["http://localhost:5173"],
    "ExposeHeaders": ["ETag"],
    "MaxAgeSeconds": 3000
  }
]
```

AWS CLI example:

```bash
aws s3api put-bucket-cors \
  --bucket 002095-offline-maps \
  --cors-configuration file://s3-cors.json \
  --region us-east-2
```

## Key APIs

Authentication:
- `POST /auth/signup`
- `POST /auth/signin`
- `POST /auth/refresh`
- `POST /auth/logout`

Profile:
- `GET /profile`
- `PATCH /profile`
- `DELETE /profile`

Locations:
- `GET /locations/countries`
- `GET /locations/states?payload=<encrypted_payload>`

Offline maps:
- `POST /offline-maps/sync-s3`
- `POST /offline-maps/initiate-upload`
- `POST /offline-maps/presigned-part`
- `POST /offline-maps/complete-upload`
- `POST /offline-maps/abort-upload`
- `GET /offline-maps`
- `GET /offline-maps/:id`
- `PATCH /offline-maps/:id`
- `PATCH /offline-maps/:id/file`
- `POST /offline-maps/:id/file/complete-upload`
- `PATCH /offline-maps/:id/status`
- `DELETE /offline-maps/:id`
- `DELETE /offline-maps/:id/hard`

Public:
- `GET /maps/url?payload=<encrypted_payload>`

## Public API Encryption

Public read APIs are protected by an encrypted API key when `PUBLIC_API_PLAINTEXT_ALLOWED=false`.

The backend uses Laravel-compatible AES encryption:
- Algorithm: `AES-256-CBC`
- Key: `sha256(substr(PUBLIC_CRYPTO_SECRET, 4, 32))`
- IV: random 16 bytes for new encrypted values
- Output: URL-safe base64 without padding

The encrypted IV is embedded in the returned token. `PUBLIC_AES_IV` is kept only for backward-compatible decrypt support for older fixed-IV tokens.

Public APIs accept the encrypted API key in either header:

```bash
x-api-key: <encrypted_public_api_key>
```

or:

```bash
x-api-key-enc: <encrypted_public_api_key>
```

When the public API key is encrypted, public API responses are returned as raw encrypted text, not wrapped JSON.

Generate encrypted values from the backend:

```bash
cd backend

API_KEY="your-public-api-key"
ENC_KEY=$(node scripts/public-api-crypto.js encrypt "$API_KEY")
```

Countries:

```bash
curl --location "http://localhost:3000/locations/countries" \
  --header "x-api-key: $ENC_KEY"
```

States:

```bash
PAYLOAD=$(node scripts/public-api-crypto.js encrypt '{"country":"India"}')

curl --location "http://localhost:3000/locations/states?payload=$PAYLOAD" \
  --header "x-api-key: $ENC_KEY"
```

Map URL:

```bash
PAYLOAD=$(node scripts/public-api-crypto.js encrypt '{"countryName":"India","stateName":"Gujarat"}')

curl --location "http://localhost:3000/maps/url?payload=$PAYLOAD" \
  --header "x-api-key: $ENC_KEY"
```

Internal crypto helper APIs are available for testing and client integration. They accept either `INTERNAL_CRYPTO_API_KEY` or `PUBLIC_API_KEY` in `x-api-key`.

Encrypt JSON:

```bash
curl -X POST "http://localhost:3000/internal/crypto/encrypt" \
  -H "x-api-key: $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"country":"India"}'
```

Encrypt plain text:

```bash
curl -X POST "http://localhost:3000/internal/crypto/encrypt" \
  -H "x-api-key: $API_KEY" \
  -H "Content-Type: text/plain" \
  --data 'hello world'
```

Decrypt:

```bash
curl -X POST "http://localhost:3000/internal/crypto/decrypt" \
  -H "x-api-key: $API_KEY" \
  -H "Content-Type: text/plain" \
  --data '<encrypted_value>'
```

Encrypted public responses can be decrypted with:

```bash
node scripts/public-api-crypto.js decrypt '<encrypted_response>'
```

## Sync Existing S3 Files

If ZIP files are uploaded directly from the AWS console, use the admin sync API to insert missing S3 objects into the database.

Expected S3 key format:

```text
offline-maps/{country}/{state}/{file-name}.zip
```

The sync also supports keys without the `offline-maps/` prefix:

```text
{country}/{state}/{file-name}.zip
```

Example:

```text
offline-maps/india/gujarat/gujarat.zip
```

The sync only inserts S3 ZIP keys that are not already present in `OfflineMap.s3Key`. Inserted records are marked:
- `status = ACTIVE` by default
- `uploadStatus = COMPLETED`

Dry run:

```bash
curl -X POST "http://localhost:3000/offline-maps/sync-s3" \
  -H "Authorization: Bearer <admin_access_token>" \
  -H "Content-Type: application/json" \
  -d '{"dryRun":true}'
```

Insert missing records:

```bash
curl -X POST "http://localhost:3000/offline-maps/sync-s3" \
  -H "Authorization: Bearer <admin_access_token>" \
  -H "Content-Type: application/json" \
  -d '{"status":"ACTIVE"}'
```

The sync clears related map URL and uploaded country/state caches for inserted records.

## Caching and Jobs

Redis cache keys:
- `locations:countries`
- `locations:states:{country}`
- `map-url:{country}:{state}`

BullMQ queue:
- `s3-cleanup-queue` deletes old or hard-deleted S3 objects with retries and logs outcomes to `BackgroundJobLog`.

## Staging Deployment

The simplest staging deployment path is Docker Compose on a Linux server with Docker and Docker Compose installed.

1. Copy the project to the staging server:

```bash
rsync -av --exclude node_modules --exclude admin-dashboard/node_modules --exclude backend/node_modules \
  offline-maps-management/ user@staging-server:/var/www/offline-maps-management/
```

2. SSH into the server and create production/staging env files:

```bash
ssh user@staging-server
cd /var/www/offline-maps-management
cp backend/.env.example backend/.env
cp admin-dashboard/.env.example admin-dashboard/.env
```

3. Update `backend/.env` for staging:

```env
DATABASE_URL=postgresql://postgres:<strong-password>@postgres:5432/offline_maps?schema=public
REDIS_HOST=redis
REDIS_PORT=6379
PORT=3000
CORS_ORIGIN=https://your-staging-dashboard-domain
AWS_REGION=
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_S3_BUCKET=
AWS_S3_PUBLIC=false
CLOUDFRONT_ENABLED=false
PUBLIC_API_KEY=
INTERNAL_CRYPTO_API_KEY=
PUBLIC_CRYPTO_SECRET=
PUBLIC_API_PLAINTEXT_ALLOWED=false
JWT_ACCESS_SECRET=
JWT_REFRESH_SECRET=
ADMIN_EMAIL=
ADMIN_PASSWORD=
```

Use long random values for `JWT_ACCESS_SECRET`, `JWT_REFRESH_SECRET`, `PUBLIC_API_KEY`, `INTERNAL_CRYPTO_API_KEY`, and `PUBLIC_CRYPTO_SECRET`.

4. Update `admin-dashboard/.env`:

```env
VITE_API_BASE_URL=https://your-staging-api-domain
```

5. Build and start services:

```bash
docker compose up -d --build
docker compose exec backend npx prisma migrate deploy
docker compose exec backend npm run seed
```

6. Verify staging:

```bash
curl https://your-staging-api-domain/health
docker compose logs -f backend
```

For public internet access, put Nginx or your cloud load balancer in front of:
- Backend container: `localhost:3000`
- Dashboard container: `localhost:5173`

Make sure HTTPS is enabled and `CORS_ORIGIN` exactly matches the dashboard URL. Also add the staging dashboard origin to the S3 bucket CORS policy so multipart browser uploads can read the `ETag` header.

## Verification

The following commands were run successfully:

```bash
cd backend && npm run prisma:generate && npm run build
cd admin-dashboard && npm run build
```

## Testing Checklist

1. Start PostgreSQL and Redis.
2. Run Prisma migration and seed the default admin.
3. Open Swagger and verify all routes are listed.
4. Sign up/sign in from the dashboard.
5. Update profile and confirm duplicate email validation.
6. Load countries and states, then confirm Redis cache keys are created.
7. Upload a small ZIP through the multipart flow before testing GB-size files.
8. Confirm uploaded map appears in the list with `COMPLETED` and `ACTIVE`.
9. Call `/maps/url` for the selected country/state and verify cache behavior.
10. Toggle active/inactive and confirm `/maps/url` no longer returns inactive maps.
11. Replace a file and confirm the old S3 key is queued for deletion.
12. Test soft delete, hard delete, pagination, search, filters, and sorting.
