Compare commits

26 Commits

Author SHA1 Message Date
419d91e292 Merge branch 'hotfix/2.21.3'
All checks were successful
Build and Push Docker Image / build_web_app__check_image_version (push) Successful in 13s
Build and Push Docker Image / build_web_app (push) Successful in 5m49s
2026-01-09 19:42:14 +01:00
a428a77eb1 (ver) web-app: version bump 2026-01-09 19:42:06 +01:00
16eaa5bfa1 (bugfix) Fix hosts file configuration by using extra_hosts at runtime
Docker overwrites /etc/hosts at container runtime, so copying it during
build (COPY command) or mounting it as volume doesn't work reliably.
Moved to extra_hosts in docker-compose files for both standalone and
swarm deployments.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-09 19:41:33 +01:00
2cff1ec18b Merge branch 'release/2.21.2'
All checks were successful
Build and Push Docker Image / build_web_app__check_image_version (push) Successful in 14s
Build and Push Docker Image / build_web_app (push) Successful in 5m57s
2026-01-09 19:18:29 +01:00
3366e85950 Merge branch 'feature/hotfix-hosts-file' into develop 2026-01-09 19:17:58 +01:00
5cb0210668 (ver) web-app: version bump 2026-01-09 19:17:53 +01:00
2ddff15497 (bugfix) Dockerfile: hosts file was copied in wrong step 2026-01-09 19:17:31 +01:00
0492469ed6 (refactor) dockerfile: modified default port 2026-01-09 19:17:01 +01:00
0ecae68c63 Merge branch 'release/2.21.1'
All checks were successful
Build and Push Docker Image / build_web_app__check_image_version (push) Successful in 15s
Build and Push Docker Image / build_web_app (push) Successful in 6m6s
2026-01-09 18:52:07 +01:00
42d1f6276a Merge branch 'feature/fixing-deploy-path' into develop 2026-01-09 18:51:48 +01:00
d17efdc156 (ver) web-app: version bump 2026-01-09 18:51:40 +01:00
de97ce744f (refactor) Move hosts file copy from volume mount to Dockerfile
Bake the custom hosts file into the Docker image instead of mounting it as a volume. This simplifies deployment configuration and makes the image more self-contained.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-09 18:51:05 +01:00
c9cc32b811 (config) Convert docker-compose paths to absolute for Portainer compatibility
Changed relative volume paths to absolute paths and updated image reference to use full registry path. This enables deployment via Portainer which doesn't have working directory context.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-09 18:49:00 +01:00
bc5f5e051f Merge branch 'release/2.21.0'
All checks were successful
Build and Push Docker Image / build_web_app__check_image_version (push) Successful in 13s
Build and Push Docker Image / build_web_app (push) Successful in 6m22s
2026-01-09 18:38:11 +01:00
528c433fce (ver) web-app: version bump 2026-01-09 18:34:59 +01:00
50238b4e90 Merge branch 'feature/gitea-workflow' into develop 2026-01-09 18:33:09 +01:00
5773156222 Add Gitea CI/CD workflows for automated Docker builds
Implements automated Docker image building and publishing to registry:

- build.yml: Main workflow that builds and pushes Docker images to registry
  - Triggers on push to master branch
  - Only builds when image with current version doesn't exist
  - Uses Docker BuildKit with layer caching for faster builds
  - Tags images with both version number and 'latest'

- check_image_version.yml: Reusable workflow to verify image existence
  - Reads version from package.json
  - Uses lightweight manifest inspection (no image download)
  - Returns image_exists and version as outputs

- check_package_version.yml: Reusable workflow to detect version changes
  - Compares version between commits
  - Handles edge cases (first commit, missing package.json)
  - Includes validation for version extraction failures

All workflows include proper error handling and clear logging.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-09 18:32:48 +01:00
a3ec20544c (refactor) Move generateShareId to locationActions and apply to LocationCard
- Moved generateShareId from shareChecksum.ts to locationActions.ts as a server action
- Updated LocationCard to use shareID with checksum for proof of payment download link
- Replaced Link with AsyncLink to handle async shareID generation
- Commented out debug console.log in Pdf417Barcode

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-09 18:18:17 +01:00
e318523887 (bugfix) Fix proof of payment download URL to use shareID with checksum
Fixed bug where proof of payment download links used raw locationID instead
of shareID (locationID + checksum), causing link validation to fail. Added
AsyncLink component to handle async shareID generation gracefully.

Changes:
- BillEditForm: Generate shareID using generateShareId server action
- BillEditForm: Use AsyncLink to prevent broken links during async load
- AsyncLink: New reusable component for links that need async data
- Updated download URL from locationID-billID to shareID-billID format

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-09 18:16:53 +01:00
37f617683e (refactor + bugfix) Improve data structure and handle empty database edge cases
Refactored months data structure from object to array for better performance
and cleaner iteration. Fixed crash when availableYears array is empty by
adding proper guards and fallback to current year.

Changes:
- MonthLocationList: Changed months prop from object to array type
- HomePage: Refactored reduce logic to build array instead of object
- HomePage: Added empty database handling in year selection logic
- HomePage: Added early returns for invalid year params in empty DB

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-09 17:36:10 +01:00
1ca55ae820 Merge branch 'master' into develop 2026-01-05 16:08:17 +01:00
0e3e41e064 2.20.3 2026-01-05 16:03:44 +01:00
488c771a09 (fix) ParamsYearInvalidMessage moved to client-side component so that it can use useEffect 2026-01-05 16:03:38 +01:00
0b8c8ae6c4 Merge branch 'master' into develop 2026-01-05 15:57:31 +01:00
1076797c89 (bugfix) HomePage: if current year was not found in DB containing data the app would crash.
This would happen at in January after user hasn't touched the app since the previous year and he did not create any records in the next (now current) year
2026-01-05 15:57:10 +01:00
Knee Cola
a54771e479 Merge branch 'hotfix/2.20.1' 2025-12-24 23:55:58 +01:00
152 changed files with 616 additions and 42985 deletions

View File

@@ -24,12 +24,7 @@
"Bash(git mv:*)",
"Bash(rmdir:*)",
"Bash(mkdir:*)",
"Bash(git diff:*)",
"Bash(grep:*)",
"Bash(for file in CommError HTTPResponseError JsonParsingError clientIpAddress)",
"Bash(do echo \"=== $file ===\")",
"Bash(done)",
"Bash(git restore:*)"
"Bash(git diff:*)"
]
},
"enableAllProjectMcpServers": true,

View File

@@ -0,0 +1,58 @@
name: Build and Push Docker Image
on:
push:
branches:
- master
jobs:
# Verifies if Docker image with current version already exists in registry
# This prevents rebuilding the same version but allows pulls and version changes
# to always trigger new builds. Uses lightweight manifest inspect (no download)
build_web_app__check_image_version:
uses: ./.gitea/workflows/check_image_version.yml
with:
workspacePath: './web-app'
imageName: 'utility-bills-tracker'
registryUrl: 'registry.budakova.org'
registryUsername: ${{ vars.PROFILE_REGISTRY_USERNAME }}
registryNamespace: 'knee-cola'
secrets:
registryToken: ${{ secrets.PROFILE_REGISTRY_TOKEN }}
# Builds and pushes Docker image to registry if:
# - Image with current version doesn't exist in registry
# This prevents rebuilding the same version unnecessarily
build_web_app:
needs: [build_web_app__check_image_version]
if: needs.build_web_app__check_image_version.outputs.image_exists == 'false'
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Registry
# Gitea automatically provides these secrets:
# - `vars.REGISTRY_USERNAME` - defined as action variable in **repo settings**
# - `secrets.REGISTRY_TOKEN` - defined as action secret in **repo settings**
# created in user settings as personal access token with `write:packages` scope
# - `vars.PROFILE_REGISTRY_USERNAME` - defined as action variable in **profile settings**
# - `secrets.PROFILE_REGISTRY_TOKEN` - defined as action secret in **profile settings**
# created in user settings as personal access token with `write:packages` scope
run: |
echo "${{ secrets.PROFILE_REGISTRY_TOKEN }}" | docker login registry.budakova.org -u "${{ vars.PROFILE_REGISTRY_USERNAME }}" --password-stdin
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: ./web-app
push: true
tags: |
registry.budakova.org/knee-cola/utility-bills-tracker:${{ needs.build_web_app__check_image_version.outputs.version }}
registry.budakova.org/knee-cola/utility-bills-tracker:latest
cache-from: type=registry,ref=registry.budakova.org/knee-cola/utility-bills-tracker:buildcache
cache-to: type=registry,ref=registry.budakova.org/knee-cola/utility-bills-tracker:buildcache,mode=max

View File

@@ -0,0 +1,94 @@
name: Check Image Version
on:
workflow_call:
inputs:
workspacePath:
description: 'Path relative to repo root where package.json is located'
required: false
type: string
default: '.'
imageName:
description: 'Docker image name without registry FQDN or username'
required: true
type: string
registryUrl:
description: 'Docker registry URL (e.g., registry.budakova.org)'
required: false
type: string
default: 'registry.budakova.org'
registryUsername:
description: 'Docker registry username'
required: true
type: string
registryNamespace:
description: 'Docker registry namespace/organization (e.g., knee-cola)'
required: true
type: string
secrets:
registryToken:
description: 'Registry access token'
required: true
outputs:
image_exists:
description: 'Whether the image exists in the registry'
value: ${{ jobs.check_image.outputs.image_exists }}
version:
description: 'Current version from package.json'
value: ${{ jobs.check_image.outputs.version }}
jobs:
check_image:
runs-on: ubuntu-latest
outputs:
image_exists: ${{ steps.manifest-check.outputs.image_exists }}
version: ${{ steps.version-read.outputs.version }}
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Read version from package.json
id: version-read
run: |
WORKSPACE_PATH="${{ inputs.workspacePath }}"
# Clean up path - remove trailing slash if present
WORKSPACE_PATH="${WORKSPACE_PATH%/}"
# Handle root directory case
if [ "$WORKSPACE_PATH" = "." ]; then
PACKAGE_JSON_PATH="package.json"
else
PACKAGE_JSON_PATH="${WORKSPACE_PATH}/package.json"
fi
VERSION=$(node -p "try { require('./${PACKAGE_JSON_PATH}').version } catch(e) { console.error('Error reading version:', e.message); process.exit(1) }") || {
echo "Error: Failed to read version from ${PACKAGE_JSON_PATH}"
exit 1
}
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "Checking for image version: $VERSION"
- name: Login to Registry
run: |
echo "${{ secrets.registryToken }}" | docker login ${{ inputs.registryUrl }} -u "${{ inputs.registryUsername }}" --password-stdin
- name: Check if image exists in registry
id: manifest-check
run: |
VERSION=${{ steps.version-read.outputs.version }}
IMAGE="${{ inputs.registryUrl }}/${{ inputs.registryNamespace }}/${{ inputs.imageName }}:${VERSION}"
echo "Checking manifest for image: $IMAGE"
if docker manifest inspect "$IMAGE" &>/dev/null; then
echo "Image exists in registry"
echo "image_exists=true" >> $GITHUB_OUTPUT
else
echo "Image does not exist in registry"
echo "image_exists=false" >> $GITHUB_OUTPUT
fi
- name: Summary
run: |
echo "Version: ${{ steps.version-read.outputs.version }}"
echo "Image exists: ${{ steps.manifest-check.outputs.image_exists }}"

View File

@@ -0,0 +1,82 @@
name: Check Package Version
on:
workflow_call:
inputs:
workspacePath:
description: 'Path relative to repo root where package.json is located'
required: false
type: string
default: '.'
outputs:
version_changed:
description: 'Whether the version changed from the previous commit'
value: ${{ jobs.check_version.outputs.version_changed }}
version:
description: 'Current version from package.json'
value: ${{ jobs.check_version.outputs.version }}
jobs:
check_version:
runs-on: ubuntu-latest
outputs:
version_changed: ${{ steps.version-check.outputs.version_changed }}
version: ${{ steps.version-check.outputs.version }}
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Check if version changed
id: version-check
run: |
WORKSPACE_PATH="${{ inputs.workspacePath }}"
# Clean up path - remove trailing slash if present
WORKSPACE_PATH="${WORKSPACE_PATH%/}"
# Handle root directory case
if [ "$WORKSPACE_PATH" = "." ]; then
PACKAGE_JSON_PATH="package.json"
else
PACKAGE_JSON_PATH="${WORKSPACE_PATH}/package.json"
fi
# Get current version
CURRENT_VERSION=$(node -p "require('./${PACKAGE_JSON_PATH}').version")
echo "Current version: $CURRENT_VERSION"
echo "version=$CURRENT_VERSION" >> $GITHUB_OUTPUT
# Check if HEAD~1 exists (handle first commit)
if ! git rev-parse HEAD~1 &>/dev/null; then
echo "First commit detected, running workflow"
echo "version_changed=true" >> $GITHUB_OUTPUT
exit 0
fi
# Check if package.json exists in previous commit
if ! git show HEAD~1:${PACKAGE_JSON_PATH} &>/dev/null; then
echo "package.json doesn't exist in previous commit"
echo "version_changed=true" >> $GITHUB_OUTPUT
exit 0
fi
# Extract previous version using grep/sed (safer than node for old file)
PREVIOUS_VERSION=$(git show HEAD~1:${PACKAGE_JSON_PATH} | grep '"version"' | head -1 | sed -E 's/.*"version"\s*:\s*"([^"]+)".*/\1/')
echo "Previous version: $PREVIOUS_VERSION"
# Validate extraction
if [ -z "$PREVIOUS_VERSION" ]; then
echo "Warning: Could not extract previous version, assuming changed"
echo "version_changed=true" >> $GITHUB_OUTPUT
exit 0
fi
# Compare versions
if [ "$CURRENT_VERSION" != "$PREVIOUS_VERSION" ]; then
echo "Version changed: $PREVIOUS_VERSION -> $CURRENT_VERSION"
echo "version_changed=true" >> $GITHUB_OUTPUT
else
echo "Version unchanged: $CURRENT_VERSION"
echo "version_changed=false" >> $GITHUB_OUTPUT
fi

2
.gitignore vendored
View File

@@ -52,5 +52,3 @@ mongo-backup/
.serena/
*.serena-memory
mongo-backup/
.git/

133
CLAUDE.md
View File

@@ -8,7 +8,6 @@ This is a multi-project repository containing:
- **web-app/**: Next.js 14 utility bills tracking application
- **docker-stack/**: Docker Compose configurations and deployment scripts
- **housekeeping/**: Database backup and maintenance scripts
- **email-worker/**: Background worker service with HTTP health monitoring
Each project is self-contained with its own dependencies.
@@ -30,13 +29,6 @@ All commands should be run from within the respective project directory.
- `./db-dump--standalone.sh` - Run standalone database dump
- See housekeeping/README.md for more details
**Email Server Worker** (`cd email-worker`):
- `npm install` - Install dependencies
- `npm run start` - Start development server with nodemon
- `npm run build` - Build TypeScript to JavaScript
- `npm run test` - Run tests with Jest in watch mode
- `npm run run-server` - Run built server from ./build directory
## Deployment Commands
**Building Docker Image** (`cd web-app`):
@@ -99,127 +91,4 @@ export const actionName = withUser(async (user: AuthenticatedUser, ...args) => {
### Testing & Code Quality
- ESLint with Next.js and Prettier configurations
- No specific test framework configured - check with user before assuming testing approach
## Email Server Worker Architecture
The email-worker is a TypeScript-based background worker service that combines periodic task execution with HTTP health monitoring and metrics collection.
### Tech Stack
- **Runtime**: Node.js with TypeScript
- **Framework**: Express for HTTP endpoints
- **Metrics**: Prometheus (prom-client) with custom PRTG adapter
- **Testing**: Jest with TypeScript support
### Core Architecture: Worker Pattern
The service implements a **self-contained worker pattern** that runs periodic background tasks while exposing HTTP endpoints for monitoring.
**Entry Point** (`email-worker/src/entry.ts:1`):
- Creates Express HTTP server with graceful shutdown support (stoppable)
- Starts the worker via `startSyncWorker()` from `email-worker/src/workRunner.ts:134`
- Handles SIGTERM/SIGINT for graceful shutdown (Docker-compatible)
- Calls `disposeSyncWorker()` on shutdown to allow pending work to complete
**Work Runner** (`email-worker/src/workRunner.ts:1`):
The work runner implements a self-scheduling loop with the following characteristics:
- **Self-Scheduling Loop**: After completing work, schedules next execution via `setTimeout(workRunner, PULL_INTERVAL)` at `email-worker/src/workRunner.ts:113`
- **Graceful Shutdown**: Tracks pending work via Promise, allows in-flight operations to complete before shutdown
- **Status Tracking**: Exports `workerRunnerInfo` with `status` and `lastWorkTime` for health monitoring
- **Error Isolation**: Worker errors don't crash the process - caught, logged, and execution continues
- **Metrics Integration**: Automatic Prometheus metrics collection (duration, success/failure counters)
- **Single Work Instance**: Ensures only one work cycle runs at a time via `pendingWork` Promise
Work Runner States (WorkerRunnerStatus enum):
- `init` - Initial state before first run
- `beginWork` - Work cycle started
- `workDone` - Work completed successfully
- `disposed` - Worker stopped, no longer scheduling
- Other states track Prometheus stats updates
**Worker Implementation Pattern**:
Workers must export a `doWork` function with signature:
```typescript
export const doWork = async () => {
// Perform periodic work here
// Throw errors to increment failedRequestCounter
// Return normally to increment successfulRequestCounter
};
```
The work runner imports and calls this function at `email-worker/src/workRunner.ts:88`.
### Key Files & Responsibilities
**Core Worker Files**:
- `email-worker/src/entry.ts` - HTTP server setup, signal handling, worker lifecycle management
- `email-worker/src/workRunner.ts` - Self-scheduling loop, graceful shutdown, metrics integration
- `email-worker/src/app.ts` - Express app configuration, route registration
- `email-worker/src/lib/logger.ts` - Debug logger factory (uses 'debug' package)
**HTTP Routes** (`email-worker/src/routes/`):
- `healthcheckRouter.ts` - Health check endpoint (checks worker status via `workerRunnerInfo`)
- `metricsRouter.ts` - Prometheus metrics endpoint
- `prtgMetricsRouter.ts` - PRTG-compatible metrics adapter
- `pingRouter.ts` - Simple ping/pong endpoint
- `errorRouter.ts` - Structured error handler for expected errors
- `finalErrorRouter.ts` - Catch-all error handler for unexpected errors
**Infrastructure**:
- `email-worker/src/lib/metricsCounters.ts` - Prometheus counter/histogram definitions
- `email-worker/src/lib/initTools.ts` - Utility functions (coalesce, etc.)
- `email-worker/src/lib/serializeError.ts` - Error serialization for logging
- `email-worker/src/lib/Prometheus2Prtg.ts` - Converts Prometheus metrics to PRTG XML format
### Environment Variables
**Required**:
- `PULL_INTERVAL` - Milliseconds between work cycles (default: "10000")
**Optional**:
- `PORT` - HTTP server port (default: "3000")
- `PROMETHEUS_APP_LABEL` - App label for Prometheus metrics (default: "evo-open-table-sync-svc")
- `PROMETHEUS_HISTOGRAM_BUCKETS` - Histogram bucket sizes (default: "0.1, 0.5, 1, 5, 10")
- `DEBUG` - Debug namespaces for console logging (e.g., "server:server")
- `ENV` - Environment mode: "dev", "jest" (affects logging behavior)
### Creating a New Worker
To implement a new worker task:
1. **Create worker file** (e.g., `email-worker/src/myWorker.ts`):
```typescript
export const doWork = async () => {
// Implement your periodic task here
logger.info("Work Title", "Work completed successfully");
// Throw errors to mark as failed:
// throw new Error("Something went wrong");
};
```
2. **Update `workRunner.ts`** import at line 6:
```typescript
import { doWork } from "./myWorker";
```
3. **Add environment variables** to `email-worker/src/types/environment.d.ts` as needed
4. **Update `package.json` metadata** if the service purpose changes (name, description)
### Docker Deployment
- Uses `stoppable` library for graceful shutdown (10-second timeout before force-close)
- Health check endpoint at `/healthcheck` verifies worker is running and not stalled
- Prometheus metrics at `/metrics` for monitoring
- PRTG-compatible metrics at `/prtg` for legacy monitoring systems
- Graceful shutdown ensures work in progress completes before container stops
### Testing
- **Framework**: Jest with esbuild-jest for TypeScript
- **Test Location**: `email-worker/tests/`
- **Mocks**: Common mocks in `email-worker/tests/__mocks__/` (prom-client)
- **Test Pattern**: Co-located with source in `tests/` mirroring `src/` structure
- **Run Tests**: `npm run test` (watch mode)
- No specific test framework configured - check with user before assuming testing approach

View File

@@ -1,36 +0,0 @@
#!/bin/bash
# CI/CD script to build and push Docker images for workspace projects
# Uses version from package.json and automatically pushes to registry
set -e # Exit on error
# List of workspaces to build
WORKSPACES=(
"mailgun-webhook"
)
printf "\n=== CI/CD Docker Image Build ===\n"
printf "Building %d workspace(s)\n\n" "${#WORKSPACES[@]}"
for WORKSPACE_DIR in "${WORKSPACES[@]}"; do
printf "\n--- Building workspace: %s ---\n\n" "$WORKSPACE_DIR"
if [ ! -d "$WORKSPACE_DIR" ]; then
printf "\nERROR: Directory '%s' does not exist. Skipping.\n\n" "$WORKSPACE_DIR"
continue
fi
if [ ! -f "$WORKSPACE_DIR/build-image.sh" ]; then
printf "\nERROR: build-image.sh not found in '%s'. Skipping.\n\n" "$WORKSPACE_DIR"
continue
fi
cd "$WORKSPACE_DIR"
./build-image.sh --auto-version --auto-push
cd ..
printf "\n--- Completed: %s ---\n" "$WORKSPACE_DIR"
done
printf "\n=== All builds completed successfully! ===\n\n"

View File

@@ -13,12 +13,15 @@ networks:
services:
web-app:
image: utility-bills-tracker:${IMAGE_VERSION}
image: registry.budakova.org/knee-cola/utility-bills-tracker:${IMAGE_VERSION}
networks:
- traefik-network
- util-bills-mongo-network
volumes:
- ./web-app/etc/hosts/:/etc/hosts
# NextJS will do name resolution for `rezije.app` and will crash if it
# resolves to an IP adress different from the one assigned to the Docker container.
# This will prevent that from happening.
extra_hosts:
- "rezije.app:0.0.0.0"
environment:
MONGODB_URI: mongodb://rezije.app:w4z4piJBgCdAm4tpawqB@mongo:27017/utility-bills
GOOGLE_ID: 355397364527-adjrokm6hromcaaar0qfhk050mfr35ou.apps.googleusercontent.com
@@ -28,7 +31,7 @@ services:
LINKEDIN_SECRET: ugf61aJ2iyErLK40
HOSTNAME: rezije.app # IP address at which the server will be listening (0.0.0.0 = listen on all addresses)
NEXTAUTH_URL: https://rezije.app # URL next-auth will use while redirecting user during authentication (if not set - will use HOSTNAME)
PORT: ${PORT:-80}
PORT: ${PORT:-3000}
# Share link security
SHARE_LINK_SECRET: ef68362357315d5decb27d24ff9abdb4a02a3351cd2899f79bf238dce0fe08c5
SHARE_TTL_INITIAL_DAYS: 10
@@ -43,7 +46,7 @@ services:
labels:
- traefik.enable=true
- traefik.docker.network=traefik-network # mreže preko koje ide komunikacija sa Traefikom
- traefik.http.services.web-app.loadbalancer.server.port=80
- traefik.http.services.web-app.loadbalancer.server.port=3000
- traefik.http.routers.web-app.entrypoints=http
- traefik.http.routers.web-app.rule=Host(`${FQDN:-rezije.app}`)
@@ -55,8 +58,8 @@ services:
MONGO_INITDB_ROOT_USERNAME: root
MONGO_INITDB_ROOT_PASSWORD: example
volumes:
- ./mongo-volume:/data/db
- ./mongo-backup:/backup
- /home/knee-cola/docker/evidencija-rezija/mongo-volume:/data/db
- /home/knee-cola/docker/evidencija-rezija/mongo-backup:/backup
networks:
- util-bills-mongo-network
mongo-express:
@@ -79,22 +82,3 @@ services:
- traefik.http.routers.mongo-express.entrypoints=http
- traefik.http.routers.mongo-express.rule=Host(`mongo.rezije.app`)
mailgun-webhook:
image: registry.budakova.org/mailgun-webhook-service:${MAILGUN_WEBHOOK_VERSION:-latest}
networks:
- traefik-network
environment:
PORT: 3000
PROMETHEUS_APP_LABEL: mailgun-webhook-service
PROMETHEUS_HISTOGRAM_BUCKETS: 0.1,0.5,1,5,10
DEBUG: server:*,app:*
MAILGUN_WEBHOOK_SIGNING_KEY: ${MAILGUN_WEBHOOK_SIGNING_KEY}
container_name: evidencija-rezija__mailgun-webhook
restart: unless-stopped
labels:
- traefik.enable=true
- traefik.docker.network=traefik-network
- traefik.http.services.mailgun-webhook.loadbalancer.server.port=3000
- traefik.http.routers.mailgun-webhook.entrypoints=http
- traefik.http.routers.mailgun-webhook.rule=Host(`mailgun-webhook.rezije.app`)

View File

@@ -17,8 +17,11 @@ services:
networks:
- traefik-network
- util-bills-mongo-network
volumes:
- ./web-app/etc/hosts/:/etc/hosts
# NextJS will do name resolution for `rezije.app` and will crash if it
# resolves to an IP adress different from the one assigned to the Docker container.
# This will prevent that from happening.
extra_hosts:
- "rezije.app:0.0.0.0"
environment:
MONGODB_URI: mongodb://rezije.app:w4z4piJBgCdAm4tpawqB@mongo:27017/utility-bills
GOOGLE_ID: 355397364527-adjrokm6hromcaaar0qfhk050mfr35ou.apps.googleusercontent.com
@@ -79,25 +82,3 @@ services:
- traefik.http.routers.mongo-express.entrypoints=http
- traefik.http.routers.mongo-express.rule=Host(`mongo.rezije.app`)
mailgun-webhook:
image: registry.budakova.org/mailgun-webhook-service:${MAILGUN_WEBHOOK_VERSION:-latest}
networks:
- traefik-network
environment:
PORT: 3000
PROMETHEUS_APP_LABEL: mailgun-webhook-service
PROMETHEUS_HISTOGRAM_BUCKETS: 0.1,0.5,1,5,10
DEBUG: server:*,app:*
MAILGUN_WEBHOOK_SIGNING_KEY: ${MAILGUN_WEBHOOK_SIGNING_KEY}
deploy:
restart_policy:
condition: any
delay: 5s
max_attempts: 0
labels:
- traefik.enable=true
- traefik.docker.network=traefik-network
- traefik.http.services.mailgun-webhook.loadbalancer.server.port=3000
- traefik.http.routers.mailgun-webhook.entrypoints=http
- traefik.http.routers.mailgun-webhook.rule=Host(`mailgun-webhook.rezije.app`)

View File

@@ -0,0 +1,27 @@
# Email Server Worker
This workspace contains the email server worker service for the Evidencija Režija tenant notification system.
## Purpose
This service manages email operations by:
- Polling MongoDB for email status changes
- Detecting unverified tenant emails (EmailStatus.Unverified)
- Sending verification emails to tenants
- Updating email status to VerificationPending
- Sending scheduled notifications (rent due, utility bills)
## Architecture
This is a standalone background worker service that:
- Runs independently from the Next.js web-app
- Communicates via the shared MongoDB database
- Integrates with email service provider (e.g., Mailgun, SendGrid)
## Setup
TBD
## Environment Variables
TBD

View File

@@ -13,7 +13,7 @@ async function sendSimpleMessage() {
console.log("Sending email...");
const data = await mg.messages.create("rezije.app", {
from: "Mailgun Sandbox <support@rezije.app>",
to: ["Nikola Derezic <armful-grief-knoll@duck.com>"],
to: ["Nikola Derezic <nikola.derezic@gmail.com>"],
subject: "Hello Nikola Derezic",
text: "Congratulations Nikola Derezic, you just sent an email with Mailgun! You are truly awesome!",
});

View File

@@ -1,7 +0,0 @@
tests
.git
coverage
node_modules
jest.config.ts
service-tester.sh
build-image.sh

View File

@@ -1,27 +0,0 @@
# Worker Configuration
PULL_INTERVAL=60000
EMAIL_BUDGET=10
# MongoDB Configuration
MONGODB_URI=mongodb://root:HjktJCPWMBtM1ACrDaw7@localhost:27017
# Mailgun Configuration
# MAILGUN_API_KEY=d660e320e0cdeecc125d4ecd6bc5cd66-e61ae8dd-01bdd61a
MAILGUN_API_KEY=f581edcac21ec14d086ef25e36f04432-e61ae8dd-e207f22b
MAILGUN_DOMAIN=rezije.app
# Security
SHARE_LINK_SECRET=fb831e43b5ab594106e093f86fa8cb2a2405c564a61c3a7681079ec416528654
# Server Configuration
PORT=3001
# Logging
DEBUG=*
# Prometheus Metrics (optional)
PROMETHEUS_APP_LABEL=email-worker
PROMETHEUS_HISTOGRAM_BUCKETS=0.1, 0.5, 1, 5, 10
# Environment
ENV=dev

View File

@@ -1,26 +0,0 @@
# Worker Configuration
PULL_INTERVAL=60000
EMAIL_BUDGET=10
# MongoDB Configuration
MONGODB_URI=mongodb://localhost:27017/utility-bills
# Mailgun Configuration
MAILGUN_API_KEY=d660e320e0cdeecc125d4ecd6bc5cd66-e61ae8dd-01bdd61a
MAILGUN_DOMAIN=rezije.app
# Security
SHARE_LINK_SECRET=fb831e43b5ab594106e093f86fa8cb2a2405c564a61c3a7681079ec416528654
# Server Configuration
PORT=3001
# Logging
DEBUG=worker:*,email:*,db:*
# Prometheus Metrics (optional)
PROMETHEUS_APP_LABEL=email-worker
PROMETHEUS_HISTOGRAM_BUCKETS=0.1, 0.5, 1, 5, 10
# Environment
ENV=dev

View File

@@ -1,2 +0,0 @@
node_modules
build

View File

@@ -1,58 +0,0 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Debug Server",
"type": "node",
"request": "launch",
"envFile": "${workspaceFolder}/.env",
"skipFiles": [
"<node_internals>/**"
],
"runtimeExecutable": "npm",
"runtimeArgs": [
"run-script",
"start"
],
"sourceMaps": true,
},
{
"type": "node",
"name": "vscode-jest-tests",
"request": "launch",
"envFile": "${workspaceFolder}/.env",
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen",
"cwd": "${workspaceFolder}",
"runtimeExecutable": "npm",
"args": [
"run",
"test",
"--",
"--runInBand",
"--watchAll=false"
]
},
{
"type": "node",
"name": "vscode-jest-tests-1634200842588",
"request": "launch",
"envFile": "${workspaceFolder}/.env",
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen",
"cwd": "${workspaceFolder}",
"runtimeExecutable": "npm",
"args": [
"run",
"test",
"--",
"--runInBand",
"--watchAll=false"
]
},
]
}

View File

@@ -1,10 +0,0 @@
{
"jest.jestCommandLine": "npm run test --",
"jest.autoRun": {
"watch": false,
"onSave": "test-file"
},
"jest.nodeEnv": {
}
}

View File

@@ -1,65 +0,0 @@
#--------------------------------------------
# Stage: building TypeScript
#--------------------------------------------
FROM node:20 as build-stage
ENV WORKDIR=/app
WORKDIR /app
COPY ./package*.json ./
# instaliram pakete
RUN npm i && npm cache clean --force
COPY ./tsconfig.json ./
COPY ./src ./src
RUN npm run build
#--------------------------------------------
# Stage: instaliram produkcijski node_modules
#--------------------------------------------
FROM node:18 as package-stage
WORKDIR /app
COPY ./package*.json ./
# instaliram SAMO produkcijske
RUN npm i --only=production && npm cache clean --force
#--------------------------------------------
# Stage: priprema finalnog image-a
#--------------------------------------------
FROM gcr.io/distroless/nodejs20-debian12:nonroot as assembly-stage
WORKDIR /app
ARG PORT="3000"
ENV PORT=${PORT}
# prometheus config
ARG PROMETHEUS_APP_LABEL
ENV PROMETHEUS_APP_LABEL=${PROMETHEUS_APP_LABEL}
ARG PROMETHEUS_HISTOGRAM_BUCKETS
ENV PROMETHEUS_HISTOGRAM_BUCKETS=${PROMETHEUS_HISTOGRAM_BUCKETS}
# (optional) logiranje na stdout (moguće opcije: "server:server", "server:metrics", "server:healthcheck" )
ARG DEBUG
ENV DEBUG=${DEBUG}
# kopiram node-modules
COPY --from=package-stage /app/package*.json ./
COPY --from=package-stage /app/node_modules ./node_modules
# kopiram buildane datoteke
COPY --from=build-stage /app/build/ ./server
# server vrtim pod ograničenim "nobody" korisnikom
USER nobody:nobody
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s \
CMD ["/nodejs/bin/node", "./server/healthcheck.js"]
# pokrećem server
CMD ["./server/entry.js"]

View File

@@ -1,115 +0,0 @@
# Email Server Worker
Background worker service with HTTP health monitoring and metrics collection.
## Overview
This is a TypeScript-based background worker service that combines periodic task execution with HTTP health monitoring and metrics collection. It implements a self-scheduling worker pattern with graceful shutdown support.
## Features
- **Periodic Task Execution**: Self-scheduling worker loop with configurable interval
- **Graceful Shutdown**: Ensures in-flight work completes before shutdown (Docker-compatible)
- **Health Monitoring**: HTTP health check endpoint to verify worker status
- **Metrics Collection**: Prometheus metrics with PRTG adapter
- **Error Isolation**: Worker errors don't crash the process
## Getting Started
### Installation
```bash
npm install
```
### Development
```bash
npm run start # Start with nodemon (auto-reload)
```
### Build & Run
```bash
npm run build # Compile TypeScript
npm run run-server # Run compiled version
```
### Testing
```bash
npm run test # Run Jest in watch mode
```
## Environment Variables
### Required
- `PULL_INTERVAL` - Milliseconds between work cycles (default: `"10000"`)
### Optional
- `PORT` - HTTP server port (default: `"3000"`)
- `PROMETHEUS_APP_LABEL` - App label for Prometheus metrics (default: `"email-worker"`)
- `PROMETHEUS_HISTOGRAM_BUCKETS` - Histogram bucket sizes (default: `"0.1, 0.5, 1, 5, 10"`)
- `DEBUG` - Debug namespaces for console logging (e.g., `"server:server"`)
- `ENV` - Environment mode: `"dev"`, `"jest"` (affects logging)
## HTTP Endpoints
- `GET /healthcheck` - Health check endpoint (verifies worker is running)
- `GET /metrics` - Prometheus metrics
- `GET /prtg` - PRTG-compatible metrics (XML format)
- `GET /ping` - Simple ping/pong endpoint
## Creating a Worker
See `src/exampleWorker.ts` for the worker template. The worker must export a `doWork` function:
```typescript
export const doWork = async () => {
// Your periodic task logic here
logger.info("Task Completed", "Work done successfully");
// Throw errors to mark as failed:
// throw new Error("Something went wrong");
};
```
Update `src/workRunner.ts` line 6 to import your worker:
```typescript
import { doWork } from "./yourWorker";
```
## Architecture
- **entry.ts** - HTTP server setup, signal handling, worker lifecycle
- **workRunner.ts** - Self-scheduling loop, metrics, graceful shutdown
- **app.ts** - Express app configuration, routes
- **src/lib/** - Shared utilities (logger, metrics, etc.)
- **src/routes/** - HTTP route handlers
## Deployment
The service uses the `stoppable` library for graceful shutdown with a 10-second timeout before force-closing connections. Docker containers will receive SIGTERM signals and shut down gracefully.
## Logging
The service supports two logging mechanisms:
1. **Console Logging**: Uses the `debug` package, controlled by `DEBUG` env variable
## Metrics
Prometheus metrics are automatically collected:
- `request_operations_total` - Total work cycles executed
- `request_operations_ok` - Successful work cycles
- `request_operations_failed` - Failed work cycles
- `request_duration_seconds` - Duration histogram of work cycles
## Documentation
See `CLAUDE.md` in the repository root for complete architecture documentation and guidance.

View File

@@ -1,29 +0,0 @@
#!/bin/bash
if [ "$1" == "" ] ; then
printf "\nDocker image version not set - please specify the version to build"
printf "\n\nSyntax:\n\n build-image.sh 1.0.0\n\n"
exit 1
fi
read -p "BUILD: Push new image to registry [y/n]? " -n 1 -r
echo # (optional) move to a new line
PUSH_IMAGE_TO_REPO="$REPLY"
printf "\nBUILD START ...\n\n"
IMAGE_NAME=evo-open-table-sync-svc
IMAGE_VERSION=$1
IMAGE_TAG=$IMAGE_NAME:$IMAGE_VERSION
docker build . -t $IMAGE_TAG
# if [[ "$PUSH_IMAGE_TO_REPO" =~ ^[Yy]$ ]]
# then
# printf "\nPushing image ...\n\n"
# docker push $IMAGE_TAG
# fi
printf "\nBUILD DONE!\n\n"

View File

@@ -1,143 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Verify Your Email - rezije.app</title>
</head>
<body style="margin: 0; padding: 0; font-family: Arial, Helvetica, sans-serif; background-color: #f4f4f4;">
<!-- Wrapper Table -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #f4f4f4;">
<tr>
<td style="padding: 20px 0;">
<!-- Main Container Table (3 columns) -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="max-width: 600px; margin: 0 auto;">
<tr>
<!-- Left Spacer Column -->
<td width="10%" style="background-color: #f4f4f4;"></td>
<!-- Center Content Column -->
<td width="80%" style="background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
<!-- Header with Logo -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="padding: 30px 20px; background-color: #3c3c3d; border-radius: 8px 8px 0 0;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="width: 80px; vertical-align: middle; padding-right: 15px;">
<img src="https://rezije.app/icon4.png" alt="rezije.app logo" style="display: block; border: none; max-width: 100%;">
</td>
<td style="vertical-align: middle; text-align: left;">
<h1 style="margin: 0; font-size: 28px; color: #ffffff; font-weight: bold;">
rezije&#8203;.&#8203;app
</h1>
<p style="margin: 5px 0 0 0; font-size: 14px; color: #BAE6FD;">
Property Management Made Easy
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
<!-- Content -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="padding: 30px 30px 20px 30px;">
<p style="margin: 0 0 20px 0; font-size: 16px; line-height: 24px; color: #333333;">
Hello <strong>${location.tenantName}</strong>,
</p>
<p style="margin: 0 0 20px 0; font-size: 16px; line-height: 24px; color: #333333;">
You have received this email because your landlord <strong>${ownerName}</strong> wants to us to send you rent and utility bills invoices for <strong>${location.name}</strong> each month.
</p>
<p style="margin: 0 0 20px 0; font-size: 16px; line-height: 24px; color: #333333;">
<strong>What is this all about?</strong><br>
<strong>rezije&#8203;.&#8203;app</strong> is an online app which helps property owners manage expenses related to properties they lease.
</p>
<p style="margin: 0 0 20px 0; font-size: 16px; line-height: 24px; color: #333333;">
By sending rent and utility bills invoices via email, your landlord can ensure that you receive accurate and timely notifications about your payments, making it easier for both of you to keep track of your financial obligations.
</p>
<p style="margin: 0 0 20px 0; font-size: 16px; line-height: 24px; color: #333333;">
<strong>How many e-mails will I receive?</strong><br>
E-mails are sent two times a month at most: once the rent is due, and once when all the utility bills ready.
</p>
<p style="margin: 0 0 20px 0; font-size: 16px; line-height: 24px; color: #333333;">
<strong>We need your confirmation</strong><br>
Before we start sending you any bills via email, we would ask you for your permission.
If you accept, please click the button below:
</p>
<!-- CTA Button -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="text-align: center; padding: 0 0 30px 0;">
<a href="https://rezije.app/en/email/verify/${shareId}"
style="display: inline-block; padding: 14px 40px; background-color: #5b5ba6 ; color: #ffffff; text-decoration: none; font-size: 16px; font-weight: bold; border-radius: 6px; border: none;">
I accept
</a>
</td>
</tr>
</table>
<p style="margin: 0 0 10px 0; font-size: 14px; line-height: 22px; color: #666666; text-align: center;">
Or copy and paste this link into your browser:
</p>
<p style="margin: 0 0 20px 0; font-size: 13px; line-height: 20px; color: #0070F3; text-align: center; word-break: break-all;">
https://rezije.app/en/email/verify/${shareId}
</p>
<!-- Divider -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="padding: 20px 0;">
<div style="border-top: 1px solid #E5E7EB;"></div>
</td>
</tr>
</table>
<p style="margin: 0 0 20px 0; font-size: 14px; line-height: 22px; color: #666666;">
<strong>Don't want to receive these emails?</strong><br>
You can ignore this email if you don't want to receive notifications. You can also unsubscribe at any time using the link included in every notification email.
</p>
<p style="margin: 0; font-size: 16px; line-height: 24px; color: #333333;">
Thank you!
</p>
</td>
</tr>
</table>
<!-- Footer -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="padding: 20px 30px; text-align: center; background-color: #F9FAFB; border-radius: 0 0 8px 8px;">
<p style="margin: 0 0 10px 0; font-size: 14px; color: #666666;">
<a href="https://rezije.app" target="_blank" style="color: #0070F3; text-decoration: none; font-weight: bold;">
Visit rezije.app
</a>
</p>
<p style="margin: 0; font-size: 12px; color: #999999;">
© 2025 rezije.app. All rights reserved.
</p>
</td>
</tr>
</table>
</td>
<!-- Right Spacer Column -->
<td width="10%" style="background-color: #f4f4f4;"></td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

View File

@@ -1,143 +0,0 @@
<!DOCTYPE html>
<html lang="hr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Potvrdite svoju e-mail adresu - rezije.app</title>
</head>
<body style="margin: 0; padding: 0; font-family: Arial, Helvetica, sans-serif; background-color: #f4f4f4;">
<!-- Wrapper Table -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #f4f4f4;">
<tr>
<td style="padding: 20px 0;">
<!-- Main Container Table (3 columns) -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="max-width: 600px; margin: 0 auto;">
<tr>
<!-- Left Spacer Column -->
<td width="10%" style="background-color: #f4f4f4;"></td>
<!-- Center Content Column -->
<td width="80%" style="background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
<!-- Header with Logo -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="padding: 30px 20px; background-color: #3c3c3d; border-radius: 8px 8px 0 0;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="width: 80px; vertical-align: middle; padding-right: 15px;">
<img src="https://rezije.app/icon4.png" alt="rezije.app logo" style="display: block; border: none; max-width: 100%;">
</td>
<td style="vertical-align: middle; text-align: left;">
<h1 style="margin: 0; font-size: 28px; color: #ffffff; font-weight: bold;">
rezije&#8203;.&#8203;app
</h1>
<p style="margin: 5px 0 0 0; font-size: 14px; color: #BAE6FD;">
Jednostavno upravljanje nekretninama
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
<!-- Content -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="padding: 30px 30px 20px 30px;">
<p style="margin: 0 0 20px 0; font-size: 16px; line-height: 24px; color: #333333;">
Poštovani <strong>${location.tenantName}</strong>,
</p>
<p style="margin: 0 0 20px 0; font-size: 16px; line-height: 24px; color: #333333;">
Primili ste ovaj email jer Vaš vlasnik nekretnine <strong>${ownerName}</strong> želi da Vam mjesečno šaljemo obavijesti o najamnini i režijama za <strong>${location.name}</strong>.
</p>
<p style="margin: 0 0 20px 0; font-size: 16px; line-height: 24px; color: #333333;">
<strong>O čemu se radi?</strong><br>
<strong>rezije&#8203;.&#8203;app</strong> je online aplikacija koja pomaže vlasnicima nekretnina da upravljaju troškovima vezanim uz nekretnine koje iznajmljuju.
</p>
<p style="margin: 0 0 20px 0; font-size: 16px; line-height: 24px; color: #333333;">
Slanjem obavijesti o najamnini i režijama putem e-maila, Vaš vlasnik može osigurati da primite točne i pravovremene obavijesti o plaćanjima, što olakšava praćenje financijskih obveza za oboje.
</p>
<p style="margin: 0 0 20px 0; font-size: 16px; line-height: 24px; color: #333333;">
<strong>Koliko e-mailova ću primati?</strong><br>
E-mailovi se šalju maksimalno dva puta mjesečno: jednom kada dospijeva najamnina, i jednom kada su sve režije spremne.
</p>
<p style="margin: 0 0 20px 0; font-size: 16px; line-height: 24px; color: #333333;">
<strong>Potrebna je Vaša potvrda</strong><br>
Prije nego što počnemo slati Vam račune putem e-maila, molimo Vas za dozvolu.
Ako prihvaćate, molimo kliknite gumb ispod:
</p>
<!-- CTA Button -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="text-align: center; padding: 0 0 30px 0;">
<a href="https://rezije.app/hr/email/verify/${shareId}"
style="display: inline-block; padding: 14px 40px; background-color: #5b5ba6 ; color: #ffffff; text-decoration: none; font-size: 16px; font-weight: bold; border-radius: 6px; border: none;">
Prihvaćam
</a>
</td>
</tr>
</table>
<p style="margin: 0 0 10px 0; font-size: 14px; line-height: 22px; color: #666666; text-align: center;">
Ili kopirajte i zalijepite ovaj link u svoj preglednik:
</p>
<p style="margin: 0 0 20px 0; font-size: 13px; line-height: 20px; color: #0070F3; text-align: center; word-break: break-all;">
https://rezije.app/hr/email/verify/${shareId}
</p>
<!-- Divider -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="padding: 20px 0;">
<div style="border-top: 1px solid #E5E7EB;"></div>
</td>
</tr>
</table>
<p style="margin: 0 0 20px 0; font-size: 14px; line-height: 22px; color: #666666;">
<strong>Ne želite primati ove e-mailove?</strong><br>
Možete zanemariti ovaj email ako ne želite primati obavijesti. Također se možete odjaviti u bilo kojem trenutku koristeći link koji se nalazi u svakoj obavijesti.
</p>
<p style="margin: 0; font-size: 16px; line-height: 24px; color: #333333;">
Hvala!
</p>
</td>
</tr>
</table>
<!-- Footer -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="padding: 20px 30px; text-align: center; background-color: #F9FAFB; border-radius: 0 0 8px 8px;">
<p style="margin: 0 0 10px 0; font-size: 14px; color: #666666;">
<a href="https://rezije.app" target="_blank" style="color: #0070F3; text-decoration: none; font-weight: bold;">
Posjetite rezije.app
</a>
</p>
<p style="margin: 0; font-size: 12px; color: #999999;">
© 2025 rezije.app. Sva prava pridržana.
</p>
</td>
</tr>
</table>
</td>
<!-- Right Spacer Column -->
<td width="10%" style="background-color: #f4f4f4;"></td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

View File

@@ -1,145 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Rent Payment Due - rezije.app</title>
</head>
<body style="margin: 0; padding: 0; font-family: Arial, Helvetica, sans-serif; background-color: #f4f4f4;">
<!-- Wrapper Table -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #f4f4f4;">
<tr>
<td style="padding: 20px 0;">
<!-- Main Container Table (3 columns) -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="max-width: 600px; margin: 0 auto;">
<tr>
<!-- Left Spacer Column -->
<td width="10%" style="background-color: #f4f4f4;"></td>
<!-- Center Content Column -->
<td width="80%" style="background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
<!-- Header with Logo -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="padding: 30px 20px; background-color: #3c3c3d; border-radius: 8px 8px 0 0;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="width: 80px; vertical-align: middle; padding-right: 15px;">
<img src="https://rezije.app/icon4.png" alt="rezije.app logo" style="display: block; border: none; max-width: 100%;">
</td>
<td style="vertical-align: middle; text-align: left;">
<h1 style="margin: 0; font-size: 28px; color: #ffffff; font-weight: bold;">
rezije&#8203;.&#8203;app
</h1>
<p style="margin: 5px 0 0 0; font-size: 14px; color: #BAE6FD;">
Property Management Made Easy
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
<!-- Content -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="padding: 30px 30px 20px 30px;">
<p style="margin: 0 0 20px 0; font-size: 16px; line-height: 24px; color: #333333;">
Hello <strong>${location.tenantName}</strong>,
</p>
<p style="margin: 0 0 30px 0; font-size: 16px; line-height: 24px; color: #333333;">
This is a friendly reminder that your rent payment for <strong>${location.name}</strong> is due on <strong>${rentDueDate}</strong>.
</p>
<!-- Rent Amount Box -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin-bottom: 30px;">
<tr>
<td style="background-color: #F0F9FF; border-left: 4px solid #0070F3; padding: 20px; border-radius: 4px;">
<p style="margin: 0 0 10px 0; font-size: 14px; color: #666666; text-transform: uppercase; letter-spacing: 0.5px;">
Amount Due
</p>
<p style="margin: 0; font-size: 32px; font-weight: bold; color: #0070F3;">
${rentAmount} ${currency}
</p>
</td>
</tr>
</table>
<p style="margin: 0 0 30px 0; font-size: 16px; line-height: 24px; color: #333333;">
To view payment details and attach your proof of payment, please click the button below:
</p>
<!-- CTA Button -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="text-align: center; padding: 0 0 30px 0;">
<a href="https://rezije.app/en/share/rent-due/${shareId}"
style="display: inline-block; padding: 14px 40px; background-color: #5b5ba6; color: #ffffff; text-decoration: none; font-size: 16px; font-weight: bold; border-radius: 6px; border: none;">
View Payment Details
</a>
</td>
</tr>
</table>
<p style="margin: 0 0 10px 0; font-size: 14px; line-height: 22px; color: #666666; text-align: center;">
Or copy and paste this link into your browser:
</p>
<p style="margin: 0 0 20px 0; font-size: 13px; line-height: 20px; color: #0070F3; text-align: center; word-break: break-all;">
https://rezije.app/en/share/rent-due/${shareId}
</p>
<!-- Divider -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="padding: 20px 0;">
<div style="border-top: 1px solid #E5E7EB;"></div>
</td>
</tr>
</table>
<p style="margin: 0 0 20px 0; font-size: 14px; line-height: 22px; color: #666666;">
If you have any questions or concerns about this payment, please contact your landlord <strong>${ownerName}</strong> directly.
</p>
<p style="margin: 0; font-size: 16px; line-height: 24px; color: #333333;">
Thank you!
</p>
</td>
</tr>
</table>
<!-- Footer -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="padding: 20px 30px; text-align: center; background-color: #F9FAFB; border-radius: 0 0 8px 8px;">
<p style="margin: 0 0 10px 0; font-size: 14px; color: #666666;">
<a href="https://rezije.app/en/email/unsubscribe/${shareId}" target="_blank" style="color: #0070F3; text-decoration: none; font-weight: bold;">
Unsubscribe from these emails
</a>
</p>
<p style="margin: 0 0 10px 0; font-size: 14px; color: #666666;">
<a href="https://rezije.app/en" target="_blank" style="color: #0070F3; text-decoration: none; font-weight: bold;">
Visit rezije.app
</a>
</p>
<p style="margin: 0; font-size: 12px; color: #999999;">
© 2025 rezije.app. All rights reserved.
</p>
</td>
</tr>
</table>
</td>
<!-- Right Spacer Column -->
<td width="10%" style="background-color: #f4f4f4;"></td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

View File

@@ -1,145 +0,0 @@
<!DOCTYPE html>
<html lang="hr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dospjela najamnina - rezije.app</title>
</head>
<body style="margin: 0; padding: 0; font-family: Arial, Helvetica, sans-serif; background-color: #f4f4f4;">
<!-- Wrapper Table -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #f4f4f4;">
<tr>
<td style="padding: 20px 0;">
<!-- Main Container Table (3 columns) -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="max-width: 600px; margin: 0 auto;">
<tr>
<!-- Left Spacer Column -->
<td width="10%" style="background-color: #f4f4f4;"></td>
<!-- Center Content Column -->
<td width="80%" style="background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
<!-- Header with Logo -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="padding: 30px 20px; background-color: #3c3c3d; border-radius: 8px 8px 0 0;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="width: 80px; vertical-align: middle; padding-right: 15px;">
<img src="https://rezije.app/icon4.png" alt="rezije.app logo" style="display: block; border: none; max-width: 100%;">
</td>
<td style="vertical-align: middle; text-align: left;">
<h1 style="margin: 0; font-size: 28px; color: #ffffff; font-weight: bold;">
rezije&#8203;.&#8203;app
</h1>
<p style="margin: 5px 0 0 0; font-size: 14px; color: #BAE6FD;">
Jednostavno upravljanje nekretninama
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
<!-- Content -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="padding: 30px 30px 20px 30px;">
<p style="margin: 0 0 20px 0; font-size: 16px; line-height: 24px; color: #333333;">
Poštovani <strong>${location.tenantName}</strong>,
</p>
<p style="margin: 0 0 30px 0; font-size: 16px; line-height: 24px; color: #333333;">
Ovo je podsjetnik da Vaša najamnina za <strong>${location.name}</strong> dospijeva <strong>${rentDueDate}</strong>.
</p>
<!-- Rent Amount Box -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin-bottom: 30px;">
<tr>
<td style="background-color: #F0F9FF; border-left: 4px solid #0070F3; padding: 20px; border-radius: 4px;">
<p style="margin: 0 0 10px 0; font-size: 14px; color: #666666; text-transform: uppercase; letter-spacing: 0.5px;">
Iznos za uplatu
</p>
<p style="margin: 0; font-size: 32px; font-weight: bold; color: #0070F3;">
${rentAmount} ${currency}
</p>
</td>
</tr>
</table>
<p style="margin: 0 0 30px 0; font-size: 16px; line-height: 24px; color: #333333;">
Za detalje o uplatama i prilaganje potvrde o plaćanju, molimo kliknite gumb ispod:
</p>
<!-- CTA Button -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="text-align: center; padding: 0 0 30px 0;">
<a href="https://rezije.app/hr/share/rent-due/${shareId}"
style="display: inline-block; padding: 14px 40px; background-color: #5b5ba6; color: #ffffff; text-decoration: none; font-size: 16px; font-weight: bold; border-radius: 6px; border: none;">
Pogledaj detalje uplate
</a>
</td>
</tr>
</table>
<p style="margin: 0 0 10px 0; font-size: 14px; line-height: 22px; color: #666666; text-align: center;">
Ili kopirajte i zalijepite ovaj link u svoj preglednik:
</p>
<p style="margin: 0 0 20px 0; font-size: 13px; line-height: 20px; color: #0070F3; text-align: center; word-break: break-all;">
https://rezije.app/hr/share/rent-due/${shareId}
</p>
<!-- Divider -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="padding: 20px 0;">
<div style="border-top: 1px solid #E5E7EB;"></div>
</td>
</tr>
</table>
<p style="margin: 0 0 20px 0; font-size: 14px; line-height: 22px; color: #666666;">
Ako imate bilo kakvih pitanja ili nedoumica u vezi ovog plaćanja, molimo kontaktirajte svog vlasnika <strong>${ownerName}</strong> izravno.
</p>
<p style="margin: 0; font-size: 16px; line-height: 24px; color: #333333;">
Hvala!
</p>
</td>
</tr>
</table>
<!-- Footer -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="padding: 20px 30px; text-align: center; background-color: #F9FAFB; border-radius: 0 0 8px 8px;">
<p style="margin: 0 0 10px 0; font-size: 14px; color: #666666;">
<a href="https://rezije.app/hr/email/unsubscribe/${shareId}" target="_blank" style="color: #0070F3; text-decoration: none; font-weight: bold;">
Odjavi se od ovih e-mailova
</a>
</p>
<p style="margin: 0 0 10px 0; font-size: 14px; color: #666666;">
<a href="https://rezije.app" target="_blank" style="color: #0070F3; text-decoration: none; font-weight: bold;">
Posjetite rezije.app
</a>
</p>
<p style="margin: 0; font-size: 12px; color: #999999;">
© 2025 rezije.app. Sva prava pridržana.
</p>
</td>
</tr>
</table>
</td>
<!-- Right Spacer Column -->
<td width="10%" style="background-color: #f4f4f4;"></td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

View File

@@ -1,145 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Utility Bills Available - rezije.app</title>
</head>
<body style="margin: 0; padding: 0; font-family: Arial, Helvetica, sans-serif; background-color: #f4f4f4;">
<!-- Wrapper Table -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #f4f4f4;">
<tr>
<td style="padding: 20px 0;">
<!-- Main Container Table (3 columns) -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="max-width: 600px; margin: 0 auto;">
<tr>
<!-- Left Spacer Column -->
<td width="10%" style="background-color: #f4f4f4;"></td>
<!-- Center Content Column -->
<td width="80%" style="background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
<!-- Header with Logo -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="padding: 30px 20px; background-color: #3c3c3d; border-radius: 8px 8px 0 0;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="width: 80px; vertical-align: middle; padding-right: 15px;">
<img src="https://rezije.app/icon4.png" alt="rezije.app logo" style="display: block; border: none; max-width: 100%;">
</td>
<td style="vertical-align: middle; text-align: left;">
<h1 style="margin: 0; font-size: 28px; color: #ffffff; font-weight: bold;">
rezije&#8203;.&#8203;app
</h1>
<p style="margin: 5px 0 0 0; font-size: 14px; color: #BAE6FD;">
Property Management Made Easy
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
<!-- Content -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="padding: 30px 30px 20px 30px;">
<p style="margin: 0 0 20px 0; font-size: 16px; line-height: 24px; color: #333333;">
Hello <strong>${location.tenantName}</strong>,
</p>
<p style="margin: 0 0 30px 0; font-size: 16px; line-height: 24px; color: #333333;">
All utility bills for <strong>${location.name}</strong> are now due.
</p>
<!-- Notification Box -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin-bottom: 30px;">
<tr>
<td style="background-color: #F0F9FF; border-left: 4px solid #0070F3; padding: 20px; border-radius: 4px;">
<p style="margin: 0 0 10px 0; font-size: 14px; color: #666666; text-transform: uppercase; letter-spacing: 0.5px;">
Total Amount Due
</p>
<p style="margin: 0; font-size: 32px; font-weight: bold; color: #0070F3;">
${totalAmount} ${currency}
</p>
</td>
</tr>
</table>
<p style="margin: 0 0 30px 0; font-size: 16px; line-height: 24px; color: #333333;">
To view bills and payment information, please click the button below:
</p>
<!-- CTA Button -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="text-align: center; padding: 0 0 30px 0;">
<a href="https://rezije.app/en/share/bills-due/${shareId}"
style="display: inline-block; padding: 14px 40px; background-color: #5b5ba6; color: #ffffff; text-decoration: none; font-size: 16px; font-weight: bold; border-radius: 6px; border: none;">
View Payment Details
</a>
</td>
</tr>
</table>
<p style="margin: 0 0 10px 0; font-size: 14px; line-height: 22px; color: #666666; text-align: center;">
Or copy and paste this link into your browser:
</p>
<p style="margin: 0 0 20px 0; font-size: 13px; line-height: 20px; color: #0070F3; text-align: center; word-break: break-all;">
https://rezije.app/en/share/bills-due/${shareId}
</p>
<!-- Divider -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="padding: 20px 0;">
<div style="border-top: 1px solid #E5E7EB;"></div>
</td>
</tr>
</table>
<p style="margin: 0 0 20px 0; font-size: 14px; line-height: 22px; color: #666666;">
If you have any questions or concerns, please contact your landlord <strong>${ownerName}</strong> directly.
</p>
<p style="margin: 0; font-size: 16px; line-height: 24px; color: #333333;">
Thank you!
</p>
</td>
</tr>
</table>
<!-- Footer -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="padding: 20px 30px; text-align: center; background-color: #F9FAFB; border-radius: 0 0 8px 8px;">
<p style="margin: 0 0 10px 0; font-size: 14px; color: #666666;">
<a href="https://rezije.app/en/email/unsubscribe/${shareId}" target="_blank" style="color: #0070F3; text-decoration: none; font-weight: bold;">
Unsubscribe from these emails
</a>
</p>
<p style="margin: 0 0 10px 0; font-size: 14px; color: #666666;">
<a href="https://rezije.app/en" target="_blank" style="color: #0070F3; text-decoration: none; font-weight: bold;">
Visit rezije.app
</a>
</p>
<p style="margin: 0; font-size: 12px; color: #999999;">
© 2025 rezije.app. All rights reserved.
</p>
</td>
</tr>
</table>
</td>
<!-- Right Spacer Column -->
<td width="10%" style="background-color: #f4f4f4;"></td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

View File

@@ -1,145 +0,0 @@
<!DOCTYPE html>
<html lang="hr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dostupne režije - rezije.app</title>
</head>
<body style="margin: 0; padding: 0; font-family: Arial, Helvetica, sans-serif; background-color: #f4f4f4;">
<!-- Wrapper Table -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #f4f4f4;">
<tr>
<td style="padding: 20px 0;">
<!-- Main Container Table (3 columns) -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="max-width: 600px; margin: 0 auto;">
<tr>
<!-- Left Spacer Column -->
<td width="10%" style="background-color: #f4f4f4;"></td>
<!-- Center Content Column -->
<td width="80%" style="background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
<!-- Header with Logo -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="padding: 30px 20px; background-color: #3c3c3d; border-radius: 8px 8px 0 0;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="width: 80px; vertical-align: middle; padding-right: 15px;">
<img src="https://rezije.app/icon4.png" alt="rezije.app logo" style="display: block; border: none; max-width: 100%;">
</td>
<td style="vertical-align: middle; text-align: left;">
<h1 style="margin: 0; font-size: 28px; color: #ffffff; font-weight: bold;">
rezije&#8203;.&#8203;app
</h1>
<p style="margin: 5px 0 0 0; font-size: 14px; color: #BAE6FD;">
Jednostavno upravljanje nekretninama
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
<!-- Content -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="padding: 30px 30px 20px 30px;">
<p style="margin: 0 0 20px 0; font-size: 16px; line-height: 24px; color: #333333;">
Poštovani <strong>${location.tenantName}</strong>,
</p>
<p style="margin: 0 0 30px 0; font-size: 16px; line-height: 24px; color: #333333;">
Sve režije za <strong>${location.name}</strong> sada su dostupne.
</p>
<!-- Notification Box -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin-bottom: 30px;">
<tr>
<td style="background-color: #F0F9FF; border-left: 4px solid #0070F3; padding: 20px; border-radius: 4px;">
<p style="margin: 0 0 10px 0; font-size: 14px; color: #666666; text-transform: uppercase; letter-spacing: 0.5px;">
Ukupan iznos za uplatu
</p>
<p style="margin: 0; font-size: 32px; font-weight: bold; color: #0070F3;">
${totalAmount} ${currency}
</p>
</td>
</tr>
</table>
<p style="margin: 0 0 30px 0; font-size: 16px; line-height: 24px; color: #333333;">
Za pregled računa i informacije o uplatama, molimo kliknite gumb ispod:
</p>
<!-- CTA Button -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="text-align: center; padding: 0 0 30px 0;">
<a href="https://rezije.app/hr/share/bills-due/${shareId}"
style="display: inline-block; padding: 14px 40px; background-color: #5b5ba6; color: #ffffff; text-decoration: none; font-size: 16px; font-weight: bold; border-radius: 6px; border: none;">
Pogledaj detalje uplate
</a>
</td>
</tr>
</table>
<p style="margin: 0 0 10px 0; font-size: 14px; line-height: 22px; color: #666666; text-align: center;">
Ili kopirajte i zalijepite ovaj link u svoj preglednik:
</p>
<p style="margin: 0 0 20px 0; font-size: 13px; line-height: 20px; color: #0070F3; text-align: center; word-break: break-all;">
https://rezije.app/hr/share/bills-due/${shareId}
</p>
<!-- Divider -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="padding: 20px 0;">
<div style="border-top: 1px solid #E5E7EB;"></div>
</td>
</tr>
</table>
<p style="margin: 0 0 20px 0; font-size: 14px; line-height: 22px; color: #666666;">
Ako imate bilo kakvih pitanja ili nedoumica, molimo kontaktirajte svog vlasnika <strong>${ownerName}</strong> izravno.
</p>
<p style="margin: 0; font-size: 16px; line-height: 24px; color: #333333;">
Hvala!
</p>
</td>
</tr>
</table>
<!-- Footer -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="padding: 20px 30px; text-align: center; background-color: #F9FAFB; border-radius: 0 0 8px 8px;">
<p style="margin: 0 0 10px 0; font-size: 14px; color: #666666;">
<a href="https://rezije.app/hr/email/unsubscribe/${shareId}" target="_blank" style="color: #0070F3; text-decoration: none; font-weight: bold;">
Odjavi se od ovih e-mailova
</a>
</p>
<p style="margin: 0 0 10px 0; font-size: 14px; color: #666666;">
<a href="https://rezije.app" target="_blank" style="color: #0070F3; text-decoration: none; font-weight: bold;">
Posjetite rezije.app
</a>
</p>
<p style="margin: 0; font-size: 12px; color: #999999;">
© 2025 rezije.app. Sva prava pridržana.
</p>
</td>
</tr>
</table>
</td>
<!-- Right Spacer Column -->
<td width="10%" style="background-color: #f4f4f4;"></td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

View File

@@ -1,39 +0,0 @@
/** @type {import('jest/dist/types').InitialOptionsTsJest} */
import type { Config } from 'jest/build/index';
const config:Config = {
// preset: 'ts-jest',
transform: {
'^.+\\.tsx?$': [
'esbuild-jest', {
sourcemap:true, // bez ovog VS code umjesto originala prikazuje transpilirane datoteke
target:'es2020' // ovo je nužno kako bi BigInt funkcionirao
}]
},
maxWorkers: 4,
testEnvironment: 'node',
// The root directory that Jest should scan for tests and modules within
rootDir: "./",
// A list of paths to directories that Jest should use to search for files in
roots: [
"<rootDir>/tests",
],
// The glob patterns Jest uses to detect test files
testMatch: [
"**/?(*.)+(spec).[tj]s?(x)",
],
// Automatically clear mock calls and instances between every test
clearMocks: true,
// An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation
transformIgnorePatterns: ["/node_modules/"],
// An array of regexp pattern strings that are matched against all test paths, matched tests are skipped
testPathIgnorePatterns: ["/node_modules/", "/build/"],
// Indicates whether each individual test should be reported during the run
verbose: true,
setupFiles: [
'dotenv/config', // učitaj varijable iz .env i učini ih dostupne testiranom software-u
]
};
module.exports = config;

View File

@@ -1,7 +0,0 @@
{
"watch": ["src"],
"ext": "ts",
"execMap": {
"ts": "node -r dotenv/config -r ts-node/register"
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,54 +0,0 @@
{
"name": "email-worker",
"version": "0.1.0",
"description": "Email notification worker service for sending verification requests, rent due notices, and utility bills notifications",
"main": "entry.ts",
"scripts": {
"start": "nodemon ./src/entry.ts",
"run-server": "DEBUG=* node --enable-source-maps ./build/entry.js",
"build": "ttsc --project ./",
"test": "ENV=jest jest --watch"
},
"author": "Nikola",
"license": "ISC",
"dependencies": {
"@evidencija-rezija/shared-code": "^1.0.0",
"debug": "^2.6.9",
"express": "^4.18.2",
"form-data": "^4.0.5",
"http-errors": "^1.7.2",
"mailgun.js": "^12.4.1",
"mongodb": "^7.0.0",
"node-fetch": "^2.6.7",
"prom-client": "^14.0.1",
"stoppable": "^1.1.0"
},
"devDependencies": {
"@babel/preset-typescript": "^7.18.6",
"@types/debug": "^4.1.7",
"@types/express": "^4.17.13",
"@types/http-errors": "^1.8.1",
"@types/jest": "^29.2.5",
"@types/mongodb": "^4.0.6",
"@types/node": "^16.10.2",
"@types/node-fetch": "^2.6.2",
"@types/stoppable": "^1.1.1",
"@types/supertest": "^2.0.11",
"dotenv": "^16.0.3",
"esbuild": "^0.16.14",
"esbuild-jest": "^0.5.0",
"jest": "^29.3.1",
"nodemon": "^2.0.13",
"supertest": "^6.3.3",
"ts-node": "^10.9.1",
"tsconfig-paths": "^4.1.2",
"ttypescript": "^1.5.15",
"typescript": "^4.9.4",
"typescript-transform-paths": "^3.4.4"
},
"babel": {
"presets": [
"@babel/preset-typescript"
]
}
}

View File

@@ -1,13 +0,0 @@
#!/bin/bash
if [ "$1" == "" ] ; then
printf "\nNisi zadao verziju Docker image-a koji treba pokrenuti"
printf "\n\nSintaksa:\n\n run-image.sh 1.0.0\n\n"
exit 1
fi
IMAGE_TAG=evo-open-table-sync-svc:$1
docker run -p 3000:3000 \
--env DEBUG=* \
$IMAGE_TAG

View File

@@ -1,34 +0,0 @@
import express from 'express';
import createError from 'http-errors';
import { errorRouter } from './routes/errorRouter';
import { finalErrorRouter } from './routes/finalErrorRouter';
import { metricsRouter } from './routes/metricsRouter';
import { pingRouter } from './routes/pingRouter';
import { healthcheckRouter } from './routes/healthcheckRouter';
import { SupportedRoutes } from './types/enums/SupportedRoutes';
const app = express();
// u slučaju kada se server vrti iza proxy-a
// ovaj flag će natjerati Express da informacije poput
// IP adrese klijenta, protokola uzima iz X-Forward-*
// HTTP header polja, koja postavlja proxy
app.set('trust proxy', true);
// prometheus sa ove rute dohvaća zadnje važeću statistiku
app.use(SupportedRoutes.metricsPath, metricsRouter);
app.use(SupportedRoutes.ping, pingRouter);
app.use(SupportedRoutes.healthcheck, healthcheckRouter);
// default handler
app.use((req, res, next) => next(createError(404)));
// error handler za sve predviđene greške
app.use(errorRouter);
// error router za nepredviđene greške
app.use(finalErrorRouter);
export default app;

View File

@@ -1,70 +0,0 @@
import { connectToDatabase, disconnectFromDatabase } from './lib/dbClient';
import { sendVerificationRequests, sendRentDueNotifications, sendUtilityBillsNotifications } from './lib/emailSenders';
import { createLogger } from './lib/logger';
const log = createLogger("worker:email");
/**
* Email worker implementation
*
* Sends three types of emails in priority order:
* 1. Email verification requests (highest priority)
* 2. Rent due notifications
* 3. Utility bills due notifications
*
* Uses a budget system to limit total emails sent per run.
*/
export const doWork = async () => {
const startTime = Date.now();
const emailBudget = parseInt(process.env.EMAIL_BUDGET || '10', 10);
log(`Starting email worker run with budget: ${emailBudget}`);
let remainingBudget = emailBudget;
let totalSent = 0;
try {
// Connect to database
const db = await connectToDatabase();
// 1. Send verification requests (highest priority)
const verificationsSent = await sendVerificationRequests(db, remainingBudget);
totalSent += verificationsSent;
remainingBudget -= verificationsSent;
log(`Verification emails sent: ${verificationsSent}, remaining budget: ${remainingBudget}`);
// 2. Send rent due notifications
if (remainingBudget > 0) {
const rentSent = await sendRentDueNotifications(db, remainingBudget);
totalSent += rentSent;
remainingBudget -= rentSent;
log(`Rent due emails sent: ${rentSent}, remaining budget: ${remainingBudget}`);
}
// 3. Send utility bills notifications
if (remainingBudget > 0) {
const billsSent = await sendUtilityBillsNotifications(db, remainingBudget);
totalSent += billsSent;
remainingBudget -= billsSent;
log(`Utility bills emails sent: ${billsSent}, remaining budget: ${remainingBudget}`);
}
// Disconnect from database
await disconnectFromDatabase();
const workDuration = Date.now() - startTime;
log(`Email worker completed in ${workDuration}ms. Total emails sent: ${totalSent}`);
} catch (error) {
log(`Email worker failed: ${error}`);
// Try to disconnect even on error
try {
await disconnectFromDatabase();
} catch (disconnectError) {
log(`Failed to disconnect from database: ${disconnectError}`);
}
throw error; // Re-throw to mark work as failed
}
};

View File

@@ -1,122 +0,0 @@
import app from './app';
import http from 'http';
import stoppable from 'stoppable';
import { createLogger } from './lib/logger';
import { disposeSyncWorker, startSyncWorker } from './workRunner';
const logger = createLogger("server:server");
/**
* Normalize a port into a number, string, or false.
*/
const normalizePort = (val:string):string|number|boolean => {
const port = parseInt(val, 10);
if (isNaN(port)) {
// named pipe
return val;
}
if (port >= 0) {
// port number
return port;
}
return false;
};
/**
* Event listener for HTTP server "error" event.
*/
const onError = (error:any):void => {
if (error.syscall !== 'listen') {
throw error;
}
const bind = typeof port === 'string'
? 'Pipe ' + port
: 'Port ' + port;
// handle specific listen errors with friendly messages
switch (error.code) {
case 'EACCES':
console.error(bind + ' requires elevated privileges');
process.exit(1);
break;
case 'EADDRINUSE':
console.error(bind + ' is already in use');
process.exit(1);
break;
default:
throw error;
}
};
/**
* Event listener for HTTP server "listening" event.
*/
const onListening = ():void => {
const addr = server.address();
const bind = typeof addr === 'string'
? 'pipe ' + addr
: 'port ' + addr?.port;
logger(`⚡️[server]: Server is running at ${bind}`);
};
/**
* Get port from environment and store in Express.
*/
const port:number|string|boolean = normalizePort(process.env.PORT || '3000');
/**
* How long should stoppable wait before it starts force-closing connections
* @description wait max 10 seconds - needs to be shorter than `healthcheck.timeout` (=15sec)
*/
const FORCE_STOP_TIMEOUT = 10000;
/**
* Create HTTP server.
*/
const server = stoppable( http.createServer(app), FORCE_STOP_TIMEOUT );
// Listen on provided port, on all network interfaces.
server.listen(port);
server.on('error', onError);
server.on('listening', onListening);
/**
* Starting sync worker process
*/
startSyncWorker();
// quit on ctrl-c when running docker in terminal
// (signal neće biti registriran ako je server pokrenuti via `npm` ili `nodemon` - mora biti pokrenuti izravno via Node)
process.on('SIGINT', () => {
logger('Got SIGINT (aka ctrl-c in docker). Graceful shutdown ', new Date().toISOString());
shutdown();
});
// quit properly on docker stop
// (signal neće biti registriran ako je server pokrenuti via `npm` ili `nodemon` - mora biti pokrenuti izravno via Node)
process.on('SIGTERM', () => {
logger('Got SIGTERM (docker container stop). Graceful shutdown ', new Date().toISOString());
shutdown();
});
// shut down server
const shutdown = async () => {
await disposeSyncWorker();
// NOTE: server.close is for express based apps
// If using hapi, use `server.stop`
server.close((err) => {
if (err) {
console.error(err);
process.exitCode = 1;
} else {
logger('Exiting server process...');
}
process.exit();
});
};

View File

@@ -1,33 +0,0 @@
/**
* Example worker implementation
*
* This is a placeholder worker that demonstrates the worker pattern.
* Replace this with your actual worker implementation.
*
* The worker is called periodically by workRunner.ts based on PULL_INTERVAL.
*
* @throws Error to increment failedRequestCounter in Prometheus
* @returns Promise that resolves when work is complete (increments successfulRequestCounter)
*/
export const doWork = async () => {
const startTime = Date.now();
// TODO: Implement your periodic worker logic here
// Examples:
// - Fetch data from external API
// - Process queued tasks from database
// - Send scheduled emails
// - Clean up expired records
// - Sync data between systems
const workDuration = Date.now() - startTime;
// Log success (only in non-test environments)
if (process.env.ENV !== "jest") {
const logMessage = `Example worker completed in ${workDuration}ms`;
console.log(logMessage);
}
// Note: Throw errors to mark work as failed:
// throw new Error("Something went wrong");
};

View File

@@ -1,29 +0,0 @@
import { createLogger } from "./lib/logger";
import http, { IncomingMessage } from "http";
const logger = createLogger("server:healthcheck");
const options = {
host: "localhost",
port: "3000",
timeout: 2000,
path: '/healthcheck/'
};
const request = http.request(options, (res:IncomingMessage) => {
logger(`Healthcheck: STATUS ${res.statusCode}`);
if (res.statusCode == 200) {
process.exit(0);
} else {
process.exit(1);
}
});
request.on("error", function (err:any) {
logger("Healthcheck: ERROR");
process.exit(1);
});
request.end();

View File

@@ -1,53 +0,0 @@
import { MongoClient, Db } from 'mongodb';
import { createLogger } from './logger';
const log = createLogger("db:client");
let client: MongoClient | null = null;
let db: Db | null = null;
/**
* Connect to MongoDB
* @returns Database instance
*/
export async function connectToDatabase(): Promise<Db> {
if (!process.env.MONGODB_URI) {
throw new Error('MONGODB_URI environment variable is not set');
}
if (db) {
log('Reusing existing database connection');
return db;
}
log('Creating new database connection');
client = new MongoClient(process.env.MONGODB_URI);
await client.connect();
db = client.db("utility-bills");
log('Connected to database');
return db;
}
/**
* Disconnect from MongoDB
*/
export async function disconnectFromDatabase(): Promise<void> {
if (client) {
log('Disconnecting from database');
await client.close();
client = null;
db = null;
log('Disconnected from database');
}
}
/**
* Get current database instance (must call connectToDatabase first)
*/
export function getDatabase(): Db {
if (!db) {
throw new Error('Database not connected. Call connectToDatabase() first.');
}
return db;
}

View File

@@ -1,284 +0,0 @@
import { Db, ObjectId } from 'mongodb';
import { BillingLocation, BillsNotificationStatus, EmailStatus, RentNotificationStatus, UserSettings, generateShareId } from '@evidencija-rezija/shared-code';
import { sendEmail } from './mailgunService';
import { createLogger } from './logger';
import { loadAndRender } from './emailTemplates';
const log = createLogger("email:senders");
/**
* Send email verification requests
* @param db Database instance
* @param budget Remaining email budget
* @returns Number of emails sent
*/
export async function sendVerificationRequests(db: Db, budget: number): Promise<number> {
if (budget <= 0) {
log('Budget exhausted, skipping verification requests');
return 0;
}
const now = new Date();
const currentYear = now.getFullYear();
const currentMonth = now.getMonth() + 1; // JavaScript months are 0-indexed
log(`Fetching locations for verification: year=${currentYear}, month=${currentMonth}`);
const locations = await db.collection<BillingLocation>('lokacije')
.find({
'yearMonth.year': currentYear,
'yearMonth.month': currentMonth,
'tenantEmailStatus': EmailStatus.Unverified
})
.toArray();
log(`Found ${locations.length} locations needing verification`);
let sentCount = 0;
for (const location of locations) {
if (budget <= 0) {
log('Budget exhausted during verification sending');
break;
}
if (!location.tenantEmail) {
log(`Skipping location ${location._id}: no tenant email`);
continue;
}
// Fetch user settings
const userSettings = await db.collection<UserSettings>('userSettings')
.findOne({ userId: location.userId });
const ownerName = userSettings?.ownerName || '';
const shareId = generateShareId(location._id.toString());
const html = loadAndRender('email-validation', {
'location.tenantName': location.tenantName || 'there',
'ownerName': ownerName,
'location.name': location.name,
'shareId': shareId
}, location.tenantEmailLanguage || 'hr');
const success = await sendEmail({
to: location.tenantEmail,
subject: `${ownerName} has invited you to rezije.app`,
html
});
// Update location status
const newStatus = success ? EmailStatus.VerificationPending : EmailStatus.VerificationFailed;
await db.collection<BillingLocation>('lokacije').updateOne(
{ _id: location._id },
{ $set: { tenantEmailStatus: newStatus } }
);
if (success) {
sentCount++;
log(`Verification email sent to ${location.tenantEmail}`);
} else {
log(`Failed to send verification email to ${location.tenantEmail}`);
}
budget--;
}
log(`Sent ${sentCount} verification emails`);
return sentCount;
}
/**
* Send rent due notifications
* @param db Database instance
* @param budget Remaining email budget
* @returns Number of emails sent
*/
export async function sendRentDueNotifications(db: Db, budget: number): Promise<number> {
if (budget <= 0) {
log('Budget exhausted, skipping rent due notifications');
return 0;
}
const now = new Date();
// Use CET timezone
const cetDate = new Date(now.toLocaleString('en-US', { timeZone: 'Europe/Belgrade' }));
const currentYear = cetDate.getFullYear();
const currentMonth = cetDate.getMonth() + 1;
const currentDay = cetDate.getDate();
log(`Fetching locations for rent due: year=${currentYear}, month=${currentMonth}, day=${currentDay}`);
const locations = await db.collection<BillingLocation>('lokacije')
.find({
'yearMonth.year': currentYear,
'yearMonth.month': currentMonth,
'tenantEmailStatus': EmailStatus.Verified,
'rentNotificationEnabled': true,
'rentDueDay': currentDay,
$or: [
{ 'rentNotificationStatus': { $exists: false } },
{ 'rentNotificationStatus': null }
]
})
.toArray();
log(`Found ${locations.length} locations needing rent due notifications`);
let sentCount = 0;
for (const location of locations) {
if (budget <= 0) {
log('Budget exhausted during rent due sending');
break;
}
if (!location.tenantEmail) {
log(`Skipping location ${location._id}: no tenant email`);
continue;
}
// Fetch user settings
const userSettings = await db.collection<UserSettings>('userSettings')
.findOne({ userId: location.userId });
const ownerName = userSettings?.ownerName || '';
const shareId = generateShareId(location._id.toString());
// Format rent due date
const rentDueDate = `${location.yearMonth.month}/${location.rentDueDay}/${location.yearMonth.year}`;
// Format rent amount (convert from cents to display format)
const rentAmount = location.rentAmount ? (location.rentAmount).toFixed(2) : '0.00';
const currency = userSettings?.currency || 'EUR';
const html = loadAndRender('rent-due', {
'location.tenantName': location.tenantName || 'there',
'location.name': location.name,
'rentDueDate': rentDueDate,
'rentAmount': rentAmount,
'currency': currency,
'ownerName': ownerName,
'shareId': shareId
}, location.tenantEmailLanguage || 'hr');
const success = await sendEmail({
to: location.tenantEmail,
subject: `Rent due for ${location.tenantName || 'your apartment'}`,
html
});
// Update location status
const newStatus = success ? RentNotificationStatus.Sent : RentNotificationStatus.Failed;
await db.collection<BillingLocation>('lokacije').updateOne(
{ _id: location._id },
{ $set: { rentNotificationStatus: newStatus } }
);
if (success) {
sentCount++;
log(`Rent due notification sent to ${location.tenantEmail}`);
} else {
log(`Failed to send rent due notification to ${location.tenantEmail}`);
}
budget--;
}
log(`Sent ${sentCount} rent due notifications`);
return sentCount;
}
/**
* Send utility bills due notifications
* @param db Database instance
* @param budget Remaining email budget
* @returns Number of emails sent
*/
export async function sendUtilityBillsNotifications(db: Db, budget: number): Promise<number> {
if (budget <= 0) {
log('Budget exhausted, skipping utility bills notifications');
return 0;
}
const now = new Date();
const currentYear = now.getFullYear();
const currentMonth = now.getMonth() + 1;
log(`Fetching locations for utility bills: year=${currentYear}, month=${currentMonth}`);
const locations = await db.collection<BillingLocation>('lokacije')
.find({
'yearMonth.year': currentYear,
'yearMonth.month': currentMonth,
'tenantEmailStatus': EmailStatus.Verified,
'billsNotificationEnabled': true,
'billsNotificationStatus': BillsNotificationStatus.Scheduled
})
.toArray();
log(`Found ${locations.length} locations needing utility bills notifications`);
let sentCount = 0;
for (const location of locations) {
if (budget <= 0) {
log('Budget exhausted during utility bills sending');
break;
}
if (!location.tenantEmail) {
log(`Skipping location ${location._id}: no tenant email`);
continue;
}
// Fetch user settings
const userSettings = await db.collection<UserSettings>('userSettings')
.findOne({ userId: location.userId });
const ownerName = userSettings?.ownerName || '';
const shareId = generateShareId(location._id.toString());
// Calculate total amount from all bills
const totalAmountCents = (location.bills || []).reduce((sum, bill) => {
return sum + (bill.payedAmount || 0);
}, 0);
const totalAmount = (totalAmountCents / 100).toFixed(2);
const currency = userSettings?.currency || 'EUR';
const html = loadAndRender('util-bills-due', {
'location.tenantName': location.tenantName || 'there',
'location.name': location.name,
'totalAmount': totalAmount,
'currency': currency,
'ownerName': ownerName,
'shareId': shareId
}, location.tenantEmailLanguage || 'hr');
const success = await sendEmail({
to: location.tenantEmail,
subject: `Utility bills due for ${location.tenantName || 'your apartment'}`,
html
});
// Update location status
const newStatus = success ? BillsNotificationStatus.Sent : BillsNotificationStatus.Failed;
await db.collection<BillingLocation>('lokacije').updateOne(
{ _id: location._id },
{ $set: { billsNotificationStatus: newStatus } }
);
if (success) {
sentCount++;
log(`Utility bills notification sent to ${location.tenantEmail}`);
} else {
log(`Failed to send utility bills notification to ${location.tenantEmail}`);
}
budget--;
}
log(`Sent ${sentCount} utility bills notifications`);
return sentCount;
}

View File

@@ -1,87 +0,0 @@
import fs from 'fs';
import path from 'path';
import { createLogger } from './logger';
const log = createLogger('email:templates');
// Cache for loaded templates
const templateCache = new Map<string, string>();
/**
* Template variable type for type-safe template rendering
*/
export type TemplateVariables = {
[key: string]: string | number | undefined;
};
/**
* Load an email template from the templates directory
* @param templateName Name of the template file (without extension)
* @param language Language code (default: 'en')
* @returns Template content as string
*/
export function loadTemplate(templateName: string, language: string = 'hr'): string {
const cacheKey = `${templateName}--${language}`;
// Check cache first
if (templateCache.has(cacheKey)) {
log(`Using cached template: ${cacheKey}`);
return templateCache.get(cacheKey)!;
}
// Construct template file path
const templateFileName = `email-template--${templateName}--${language}.html`;
const templatePath = path.join(__dirname, '../../email-templates', templateFileName);
try {
const content = fs.readFileSync(templatePath, 'utf-8');
templateCache.set(cacheKey, content);
log(`Loaded template: ${templateFileName}`);
return content;
} catch (error) {
log(`Failed to load template ${templateFileName}: ${error}`);
throw new Error(`Template not found: ${templateFileName}`);
}
}
/**
* Render a template by replacing variables
* @param template Template content
* @param variables Object with variable values
* @returns Rendered HTML string
*/
export function renderTemplate(template: string, variables: TemplateVariables): string {
let rendered = template;
// Replace all ${variable} occurrences
for (const [key, value] of Object.entries(variables)) {
if (value !== undefined) {
const regex = new RegExp(`\\$\\{${key}\\}`, 'g');
rendered = rendered.replace(regex, String(value));
}
}
// Log warning if there are unreplaced variables
const unreplacedMatches = rendered.match(/\$\{[^}]+\}/g);
if (unreplacedMatches) {
log(`Warning: Unreplaced variables in template: ${unreplacedMatches.join(', ')}`);
}
return rendered;
}
/**
* Load and render an email template in one step
* @param templateName Name of the template file (without extension)
* @param variables Object with variable values
* @param language Language code (default: 'en')
* @returns Rendered HTML string
*/
export function loadAndRender(
templateName: string,
variables: TemplateVariables,
language: string = 'hr'
): string {
const template = loadTemplate(templateName, language);
return renderTemplate(template, variables);
}

View File

@@ -1,8 +0,0 @@
/**
* Za neinicijaliziranu env varijablu vraća default vrijednost
* @param value vrijednost env varijable
* @param defaultValue default vrijednost
* @returns
*/
export const coalesce = (value:string|undefined, defaultValue:string):string => value===undefined ? defaultValue : (value==="" ? defaultValue : value);

View File

@@ -1,21 +0,0 @@
import debug from 'debug';
/**
* Logs to console / stdout
* @param namespace
* @returns instance of Debug
*/
export const createLogger = (namespace:string):debug.Debugger => {
const dbg = debug(namespace);
const rx = /nodemon/gi;
if(rx.test(process.env?.npm_lifecycle_script ?? "")) {
// When started via nodemon:
// forcing the use of console insted of stdout
// -> nodemon doesn't work with stdout
dbg.log = console.log.bind(console);
}
return(dbg);
};

View File

@@ -1,67 +0,0 @@
import formData from 'form-data';
import Mailgun from 'mailgun.js';
import { createLogger } from './logger';
const log = createLogger("email:mailgun");
export interface EmailMessage {
to: string;
subject: string;
html: string;
}
let mailgunClient: any = null;
/**
* Initialize Mailgun client
*/
function getMailgunClient() {
if (mailgunClient) {
return mailgunClient;
}
const apiKey = process.env.MAILGUN_API_KEY;
if (!apiKey) {
throw new Error('MAILGUN_API_KEY environment variable is not set');
}
const mailgun = new Mailgun(formData);
mailgunClient = mailgun.client({
username: 'api',
key: apiKey,
url: "https://api.eu.mailgun.net"
});
return mailgunClient;
}
/**
* Send an email using Mailgun
* @param message Email message to send
* @returns True if successful, false otherwise
*/
export async function sendEmail(message: EmailMessage): Promise<boolean> {
try {
const client = getMailgunClient();
const domain = process.env.MAILGUN_DOMAIN || 'rezije.app';
const messageData = {
from: 'rezije.app <noreply@rezije.app>',
to: message.to,
subject: message.subject,
html: message.html,
"v:locationID": "12345" // testing custom variable - webhook should pick this up
};
log(`Sending email to ${message.to}: ${message.subject}`);
const response = await client.messages.create(domain, messageData);
log(`Email sent successfully to ${message.to}, ID: ${response.id}`);
return true;
} catch (error) {
log(`Failed to send email to ${message.to}: ${error}`);
return false;
}
}

View File

@@ -1,50 +0,0 @@
import { Counter, Histogram, register } from 'prom-client';
import { coalesce } from './initTools';
/** Histogram Buckets */
const PROMETHEUS_HISTOGRAM_BUCKETS = coalesce(process.env.PROMETHEUS_HISTOGRAM_BUCKETS, "0.1, 0.5, 1, 5, 10");
/** Labela kojom želimo da bude označena metrika prikupljena na ovom web servisu */
const PROMETHEUS_APP_LABEL = coalesce(process.env.PROMETHEUS_APP_LABEL, 'email-worker');
// na "app" labele ćemo razdvajanje rezultata u Grafani
register.setDefaultLabels({ app: PROMETHEUS_APP_LABEL });
/**
* Broji koliko je ukupno zahtjeva zaprimljeno za obradu
*/
export const totalRequestCounter = new Counter({
name: "request_operations_total",
help: "ukupan broj zaprimljenih zahtjeva",
/** countere razdvajamo po vrsti zahtjeva */
labelNames: ['path'],
});
/**
* Broji zahtjeve koji su uspješno obrađeni
*/
export const successfulRequestCounter = new Counter({
name: "request_operations_ok",
help: "broj zahtjeva koji su uspješno obrađeni",
/** countere razdvajamo po vrsti zahtjeva */
labelNames: ['path'],
});
/**
* Broji zahtjeve kod čije obrade je došlo do greške
*/
export const failedRequestCounter = new Counter({
name: "request_operations_failed",
help: "broj zahtjeva kod čije obrade je došlo do greške",
/** countere razdvajamo po vrsti zahtjeva i rezultatu izvođenja */
labelNames: ["path", "status"],
});
/** Histogram mjeri koliko traje obrada pristiglog zahtjeva */
export const requestDurationHistogram = new Histogram({
name: "request_duration_seconds",
help: "Trajanje request-a u sekundama",
/** countere razdvajamo po vrsti zahtjeva i rezultatu izvođenja */
labelNames: ["path", "status"],
buckets: PROMETHEUS_HISTOGRAM_BUCKETS?.split(',').map((el) => parseFloat(el))
});

View File

@@ -1,19 +0,0 @@
/**
* This function serializes an error object into a string that can be logged
* @param ex error object
* @returns string
* @description SQL Server may generate more than one error for one request so you can access preceding errors with `err.precedingErrors`, while the `ex` itself is a generic error without any useful information
*/
export const serializeError = (ex:Error | Error & { precedingErrors?:Error[] }):string => {
const { name, message, stack, precedingErrors } = (ex as Error & { precedingErrors?:Error[] });
// SQL Server may generate more than one error for one request so you can access preceding errors with `ex.precedingErrors`,
// while the `ex` itself is a generic error without any useful information
if(precedingErrors) {
return(serializeError(precedingErrors[0]));
}
return `${name}:${message}`;
}

View File

@@ -1,81 +0,0 @@
import { ErrorRequestHandler, Request, Response } from "express";
import createHttpError, { HttpError } from "http-errors";
import { createLogger } from '../lib/logger';
import { NgitLocals } from "../types/NgitLocals";
import { failedRequestCounter } from "../lib/metricsCounters";
import { SupportedRoutes } from "../types/enums/SupportedRoutes";
const consoleLog = createLogger("server:server");
/**
* Router koji se zadnji poziva, a koji sastavlja odgovor u slučaju greške
* @param err
* @param req
* @param res
* @param next
*/
export const errorRouter:ErrorRequestHandler = async (err:HttpError, req, res, next) => {
const requestPath = req.path as SupportedRoutes;
// kako je ovaj error handler dosta složen, moguće je da negdje baci grešku
// > zato je zamotan u try-catch
// > na taj način osiguravam da neće srušiti cijeli proces
try {
let { name:errorLogName, message:errorLogText } = err;
let responseBody:string = "";
switch(err.status) {
case 400:
responseBody = 'bad request';
break;
case 401:
responseBody = 'unauthorized';
break;
case 403:
responseBody = 'forbidden';
break;
case 404:
consoleLog(`page not found ${req.method} ${requestPath}`)
responseBody = 'page not found';
errorLogText = `page ${requestPath} not found`;
break;
case 500:
responseBody = "internal server error";
errorLogText = err.message;
break;
default:
responseBody = err.name;
errorLogText = `err.status=${err.status};err.name=${err.name};err.message=${err.message}`;
}
consoleLog(`${errorLogName}:${errorLogText}`);
// `headersSent` će biti TRUE ako je router kod kojeg se dogodila greška već poslao header-e
// > ako ih probam ponovo postaviti, to će baciti grešku ... a to ovdje mogu izbjeći
if(!res.headersSent) {
res.status(err.status);
res.setHeader('Content-Type', "text/html");
res.end(responseBody);
} else {
// AKO nije pozvan `end` - pozovi ga i završi obradu zahtjeva
// ... u suprotnom će konekcija ostati otvorena do timeout-a
if(!res.writableEnded) {
res.end();
}
}
} catch(ex:any) {
// ovu grešku će obraditi `finalErrorRouter`
next(createHttpError(500, ex));
}
// ne mogu dopustiti da prometheus client sruši server
try {
failedRequestCounter.inc({ path: requestPath, status: err.status });
(res.locals as NgitLocals).stopPrometheusTimer({ path: req.path, status: err.status });
} catch(ex:any) {
console.error(ex);
}
};

View File

@@ -1,34 +0,0 @@
import { ErrorRequestHandler, Request, Response } from "express";
import { HttpError } from "http-errors";
import { createLogger } from '../lib/logger';
import { NgitLocals } from "../types/NgitLocals";
const consoleLog = createLogger("server:server");
/**
* Router koji se izvršava u slučaju grube greške koja nije obrađena nigdje prije
* @param err error objekt
* @param req express request
* @param res express response
* @param next
*/
export const finalErrorRouter:ErrorRequestHandler = async (err:HttpError, req, res, next) => {
const errorLogText:string = JSON.stringify({ message:err.message, name:err.name, stack:err.stack });
consoleLog(`Server Error ${err.status}\n${errorLogText}`);
// `headersSent` će biti TRUE ako je router kod kojeg se dogodila greška već poslao header-e
// > ako ih probam ponovo postaviti, to će baciti grešku i u ovom slučaju SRUŠITI SERVER - to ne smijemo dopustiti
if(!res.headersSent) {
res.status(err.status);
res.setHeader('Content-Type', "text/html");
res.end(`unhandled server error`);
} else {
// AKO nije pozvan `end` - pozovi ga i završi obradu zahtjeva
// ... u suprotnom će konekcija ostati otvorena do timeout-a
if(!res.writableEnded) {
res.end();
}
}
};

View File

@@ -1,35 +0,0 @@
import { RequestHandler, Router } from "express";
import { workerRunnerInfo } from "../workRunner";
import { coalesce } from "../lib/initTools";
const PULL_INTERVAL = parseInt(coalesce(process.env.PULL_INTERVAL, "10000"));
/** Maximum time between two worker jobs */
const MAX_WORKER_LATENCY = PULL_INTERVAL * 2.5;
/**
* Router koji se izvršava u slučaju grube greške koja nije obrađena nigdje prije
* @param req express request
* @param res express response
* @param next
*/
export const healthcheckRouter:RequestHandler = async (req, res, next) => {
const workerLatency = Date.now() - workerRunnerInfo.lastWorkTime;
if(workerLatency > MAX_WORKER_LATENCY) {
const msg = `No work done in ${workerLatency}ms. Last worker status = "${workerRunnerInfo.status}"`;
console.warn(msg)
res.status(500);
res.setHeader('Content-Type', 'text/plain');
res.end(msg);
} else {
res.status(200);
res.setHeader('Content-Type', 'text/plain');
res.end('OK');
}
};
export const pingRouter = Router();
pingRouter.get('/', healthcheckRouter);

View File

@@ -1,19 +0,0 @@
import { Router, NextFunction, Request, Response } from "express";
import createError from 'http-errors';
import { register } from 'prom-client';
import { createLogger } from '../lib/logger';
const logger = createLogger("server:metrics");
export const metricsRouter = Router();
metricsRouter.get('/', async (req:Request, res:Response, next:NextFunction) => {
// ne mogu dopustiti da prometheus client sruši server
try {
logger(`⚡️[server]: GET /metrics`);
res.set('Content-Type', register.contentType);
res.end(await register.metrics());
} catch(ex:any) {
next(createError(500, (ex as Error).message));
}
});

View File

@@ -1,16 +0,0 @@
import { RequestHandler, Router } from "express";
/**
* Router koji se izvršava u slučaju grube greške koja nije obrađena nigdje prije
* @param req express request
* @param res express response
* @param next
*/
export const pingRequestHandler:RequestHandler = async (req, res, next) => {
res.status(200);
res.setHeader('Content-Type', 'text/plain');
res.end('PONG');
};
export const pingRouter = Router();
pingRouter.get('/', pingRequestHandler);

View File

@@ -1,7 +0,0 @@
import { LabelValues } from "prom-client";
/** data assignet to `express.response.locals` */
export type NgitLocals = {
/** Prometheus client timer */
stopPrometheusTimer: (labels?: LabelValues<"path"|"status">) => number,
};

View File

@@ -1,5 +0,0 @@
export enum SupportedRoutes {
metricsPath='/metrics',
ping='/ping',
healthcheck='/healthcheck',
}

View File

@@ -1,60 +0,0 @@
declare global {
namespace NodeJS {
interface ProcessEnv {
/**
* (optional) environment u kojem se proces vrti
* @default undefined
* */
ENV?:"dev"|"jest"
/**
* (optional) App label to be used in Prometheus (Grafana)
* @default "email-worker"
* */
PROMETHEUS_APP_LABEL?: string
/**
* (optional) Prometheus histogram bucket sizes (grafana)
* @default "0.1, 0.5, 1, 5, 10"
* */
PROMETHEUS_HISTOGRAM_BUCKETS?: string
/**
* (required) Pull interval in milliseconds - how often should worker cycle run
* @default "10000"
* */
PULL_INTERVAL:string
/**
* (required) MongoDB connection URI
* */
MONGODB_URI: string
/**
* (required) Mailgun API key for sending emails
* */
MAILGUN_API_KEY: string
/**
* (optional) Mailgun domain
* @default "rezije.app"
* */
MAILGUN_DOMAIN?: string
/**
* (required) Secret key for generating share link checksums
* */
SHARE_LINK_SECRET: string
/**
* (optional) Maximum number of emails to send per worker run
* @default "10"
* */
EMAIL_BUDGET?: string
/**
* (optional) HTTP server port
* @default "3000"
* */
PORT?: string
/**
* (optional) Debug namespaces for console logging
* */
DEBUG?: string
}
}
}
export {}

View File

@@ -1,165 +0,0 @@
import { failedRequestCounter, requestDurationHistogram, successfulRequestCounter, totalRequestCounter } from "./lib/metricsCounters";
import { coalesce } from "./lib/initTools";
import { createLogger } from "./lib/logger";
import { serializeError } from "./lib/serializeError";
import { doWork } from "./emailWorker";
/** time between two pull operations */
const PULL_INTERVAL = parseInt(coalesce(process.env.PULL_INTERVAL, "10000"));
const consoleLog = createLogger("server:server");
/** Writes entry to log */
const logWrite = (logTitle:string, logMessage:string) => {
consoleLog(`${logTitle}: ${logMessage}}`);
}
/** Writes error to log */
const logError = (ex: any) =>
logWrite(serializeError(ex), "error");
/**
* zastavica za zaustavljanje sinhronizacije
*/
let disposed:boolean = false;
/** is worker started - prevents multiple starts */
let workerStarted:boolean = false;
/** Promise which is resolved once the pending work in progress is completed */
let pendingWork:Promise<void>|undefined;
/** Worker re-run timeout */
let pendingTimeout:NodeJS.Timeout|undefined;
/** Enumeracija pojedinih statusa obrade jednog work-a */
export enum WorkerRunnerStatus {
init="init",
disposed="disposed",
beginWork="beginWork",
updatedStats1="updatedStats1",
updatedStats2="updatedStats2",
stoppedStatTimer="stoppedStatTimer",
workDone="workDone",
newIntervalScheduled="newIntervalScheduled",
currentWorkResolved="currentWorkResolved",
}
/** Info o statusu workera */
export type WorkerRunnerInfo = {
/** zadnje izvršena readnja */
status: WorkerRunnerStatus,
/** vrijeme kada je worker zadnji puta pokrenut */
lastWorkTime: number,
}
/** Info o statusu workera, koji koristi healthcheck kako bi vidio da li stvar funkcionira */
export const workerRunnerInfo:WorkerRunnerInfo = {
status: WorkerRunnerStatus.init,
lastWorkTime: Date.now()
}
export const workRunner = async () => {
pendingTimeout = undefined;
workerRunnerInfo.lastWorkTime = Date.now();
workerRunnerInfo.status = WorkerRunnerStatus.beginWork;
// AKO je modul zaustavljen
// -> nemoj se pokrenuti
if(disposed) {
workerRunnerInfo.status = WorkerRunnerStatus.disposed;
return;
}
// kreiram Promise koji omogućuje da dispose zna
// pričekati da worker završi sa poslom (ako je u tom trenutku aktivan)
pendingWork = new Promise(async (resolve) => {
try {
totalRequestCounter.inc();
const stopPrometheusTimer = requestDurationHistogram.startTimer();
workerRunnerInfo.status = WorkerRunnerStatus.updatedStats1;
try {
// ne dopuštam da stvar sruši worker
await doWork();
workerRunnerInfo.status = WorkerRunnerStatus.workDone;
// ažuriram statistiku
successfulRequestCounter.inc();
workerRunnerInfo.status = WorkerRunnerStatus.updatedStats2;
} catch(ex:any) {
// ažuriram statistiku
failedRequestCounter.inc();
logError(ex);
}
stopPrometheusTimer();
workerRunnerInfo.status = WorkerRunnerStatus.stoppedStatTimer;
} catch(ex:any) {
logError(ex);
}
// nemoj pokrenuti timer ako je worker u međuvremenu disposed
if(!disposed) {
// pull again after timeout
pendingTimeout = setTimeout(workRunner, PULL_INTERVAL);
workerRunnerInfo.status = WorkerRunnerStatus.newIntervalScheduled;
} else {
logWrite("Info", "... exiting worker loop");
}
resolve();
workerRunnerInfo.status = WorkerRunnerStatus.currentWorkResolved;
pendingWork = undefined;
});
// this is an async function which must return a promise
// > so return the promise which will be resolved once the work is done
return(pendingWork);
};
/**
* Starts the worker
*/
export const startSyncWorker = () => {
if(!workerStarted && !disposed) {
workerStarted = true;
workRunner();
logWrite("Info", "Worker Started");
};
}
/**
* Stops and disposes the worker
*/
export const disposeSyncWorker = async () => {
logWrite("Info", "Disposing worker ...");
disposed = true;
// preventing timer from trigger another work cycle
if(pendingTimeout) {
clearTimeout(pendingTimeout);
}
// IF no work is currently in progress
// > return a resolved promise
if(!pendingWork) {
return(Promise.resolve());
}
await pendingWork;
logWrite("Info", "Worker disposed!");
}
/** Ovo se koristi samo za Unit Testing */
export const reset_dispose = () => {
disposed = false;
}

View File

@@ -1,29 +0,0 @@
import { LabelValues } from "prom-client";
export class Counter {
public inc() {
}
}
export class Histogram<T extends string> {
startTimer(labels?: LabelValues<T>): (labels?: LabelValues<T>) => void {
return((labels?: LabelValues<T>) => { });
}
}
class Register {
public setDefaultLabels(labels: Object) {
}
public metrics(): Promise<string> {
return(Promise.resolve(""));
}
public get contentType() {
return("");
}
}
export const register = new Register();

View File

@@ -1,33 +0,0 @@
import { Request, Response, NextFunction } from 'express';
import { NgitLocals } from "../../src/types/NgitLocals";
interface IMockHttpContext {
reqPath?:string
headersSent?:boolean
writableEnded?:boolean
method?:string
}
export const mockHttpContext = ({reqPath="/", headersSent=false, writableEnded=false, method="GET"}:IMockHttpContext|undefined = {}) => {
const req = {
path:reqPath,
method,
url:`https://localhost${reqPath}`,
params: {},
} as unknown as Request;
const res = {
end: jest.fn(),
status: jest.fn(),
setHeader: jest.fn(),
locals: {
stopPrometheusTimer: jest.fn(),
} as unknown as NgitLocals,
headersSent,
writableEnded,
} as unknown as Response;
const next:NextFunction = jest.fn();
return({req,res,next})
}

View File

@@ -1,118 +0,0 @@
import { errorRouter } from '../../src/routes/errorRouter';
import createError from "http-errors";
import { mockHttpContext } from "../helpers/mockHttpContext";
describe("errorRouter", () => {
beforeEach(() => {
mockWrite.mockClear();
});
test("u slučaju greške 404 mora vratiti string poruku 'page not found'", async () => {
const err = createError(404)
const {req,res,next} = mockHttpContext();
await errorRouter(err, req, res, next);
expect(res.status).toHaveBeenCalledWith(404);
expect(res.setHeader).toHaveBeenCalledWith('Content-Type', "text/html");
expect(res.end).toHaveBeenCalledWith("page not found");
});
test("u slučaju greške 404 mora logirati request, response i tekst greške", async () => {
const err = createError(404)
const reqPath = "/neki-path/";
const {req,res,next} = mockHttpContext({ reqPath });
await errorRouter(err, req, res, next);
expect(res.locals.logger.info).toHaveBeenCalledWith("response", "page not found");
expect(res.locals.logger.error).toHaveBeenCalledWith(err.name, "page "+req.path+" not found");
});
test("ako su header-i već poslani, ne smiju biti poslani još jednom", async () => {
const err = createError(404)
const {req,res,next} = mockHttpContext({ headersSent:true, writableEnded:true });
await errorRouter(err, req, res, next);
expect(res.status).not.toHaveBeenCalled();
expect(res.setHeader).not.toHaveBeenCalled();
expect(res.end).not.toHaveBeenCalled();
});
test("ako NIJE već pozvana [end] metoda, treba je pozvati", async () => {
const err = createError(404)
const {req,res,next} = mockHttpContext({ headersSent:true, writableEnded:false });
await errorRouter(err, req, res, next);
expect(res.status).not.toHaveBeenCalled();
expect(res.setHeader).not.toHaveBeenCalled();
expect(res.end).toHaveBeenCalled();
});
test("mora zaustaviti Prometheus Timer", async () => {
const err = createError(404)
const {req,res,next} = mockHttpContext({ headersSent:true, writableEnded:false });
await errorRouter(err, req, res, next);
expect(res.locals.stopPrometheusTimer).toHaveBeenCalled();
});
test("u slučaju greške 500 mora vratiti string poruku 'internal server error'", async () => {
const err = createError(500)
const {req,res,next} = mockHttpContext();
await errorRouter(err, req, res, next);
expect(res.status).toHaveBeenCalledWith(500);
expect(res.setHeader).toHaveBeenCalledWith('Content-Type', "text/html");
expect(res.end).toHaveBeenCalledWith("internal server error");
});
test("u slučaju greške 400 mora vratiti string poruku 'bad request' i logirati grešku", async () => {
const errorMessage = "mock error text 1";
const err = createError(400, errorMessage);
const {req,res,next} = mockHttpContext();
await errorRouter(err, req, res, next);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.setHeader).toHaveBeenCalledWith('Content-Type', "text/html");
expect(res.end).toHaveBeenCalledWith("bad request");
expect(res.locals.logger.errorwrite).toHaveBeenCalledWith(err.name, errorMessage);
});
test("u slučaju greške 401 mora vratiti string poruku 'unauthorized' i logirati grešku", async () => {
const errorMessage = "mock error text 2";
const err = createError(401, errorMessage)
const {req,res,next} = mockHttpContext();
await errorRouter(err, req, res, next);
expect(res.status).toHaveBeenCalledWith(401);
expect(res.setHeader).toHaveBeenCalledWith('Content-Type', "text/html");
expect(res.end).toHaveBeenCalledWith("unauthorized");
expect(res.locals.logger.error).toHaveBeenCalledWith(err.name, errorMessage);
});
test("u slučaju greške 403 mora vratiti string poruku 'forbidden' i logirati grešku", async () => {
const errorMessage = "mock error text 3";
const err = createError(403, errorMessage);
const {req,res,next} = mockHttpContext();
await errorRouter(err, req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.setHeader).toHaveBeenCalledWith('Content-Type', "text/html");
expect(res.end).toHaveBeenCalledWith("forbidden");
expect(res.locals.logger.error).toHaveBeenCalledWith(err.name, errorMessage);
});
});

View File

@@ -1,36 +0,0 @@
{
"compilerOptions": {
"target": "es2020", // https://github.com/microsoft/TypeScript/wiki/Node-Target-Mapping
"module": "commonjs",
"esModuleInterop": true, // solves the problem regarding the default importa vs importa *
"strict": true,
"sourceMap": true, // please do create source maps
"skipLibCheck": true, // don't verify typescript of 3rd party modules
"rootDir": "src", // root directory under which source files are located - it's subtree will be mirrored in "outDir"
"outDir": "build", // where the build files should be stored
// "baseUrl" ---- se NE SMIJE koristiti
// POJAŠNJENJE: ako zadamo "baseUrl" Intellisense će početi kod autocompletion-a (Ctrl+Space)
// umjesto relativnih insertirati apsolutni path do modula,
// a takav path nije dobar za build niti debugging
// "baseUrl": "./", // set a base directory to resolve non-absolute module names - This must be specified if "paths" is used
"paths": {
},
"plugins": [
{
// Slijedeće je namijenjeno BUILD projekta
// POJAŠNJENJE: build tadi `ttypescript`
// koji ne zna interpretirati što je podešeno pod "path"
// > to za njega rješava "typescript-transform-paths"
"transform": "typescript-transform-paths"
}
]
},
"include": ["src/**/*"], // location of files which need to be compiled
// Slijedeće je namijenjeno DEBUGGING servera u VS Code-u
// POJAŠNJENJE: kod debugginga modul se pokreće pomoću `ts-node`,
// koji ne zna sam interpretirati što je podešeno pod "paths"
// > to za njega rješava "tsconfig-paths/register"
"ts-node": {
"require": ["tsconfig-paths/register"]
},
}

View File

@@ -17,12 +17,8 @@
"path": "mailgun-webhook"
},
{
"name": "⚙️ email-worker",
"path": "email-worker"
},
{
"name": "🔗 shared-code",
"path": "shared-code"
"name": "⚙️ email-server-worker",
"path": "email-server-worker"
},
{
"name": "📦 root",

View File

@@ -1,7 +0,0 @@
tests
.git
coverage
node_modules
jest.config.ts
service-tester.sh
build-image.sh

View File

@@ -1,5 +0,0 @@
# This file defines enviroment variables used in development environment
# It will be ommited from Docker image by the build process
# in dev environment Web6 app uses port 3000, so we need to make sure we use different port
PORT="4000"

View File

@@ -1,26 +0,0 @@
# Environment Variables for MailGun Webhook Service
# Copy this file to .env for local development
# Server Configuration
PORT=3000
# Prometheus Monitoring
# Label that will mark the metric in Prometheus (default: package.json name)
PROMETHEUS_APP_LABEL=mailgun-webhook-service
# CSV definition of buckets for Prometheus/Grafana histogram
PROMETHEUS_HISTOGRAM_BUCKETS=0.1,0.5,1,5,10
# Debug Logging
# Enable debug output for specific namespaces
# Examples: server:*, app:*, or * for all
DEBUG=server:*,app:*
# MailGun Configuration (for future enhancements)
# Uncomment and configure when adding webhook signature verification
# MAILGUN_SIGNING_KEY=your-mailgun-signing-key
# MAILGUN_WEBHOOK_TIMEOUT=30000
# Security Configuration (optional)
# Uncomment to restrict webhook access to MailGun IPs only
# ALLOWED_IPS=209.61.151.0/24,209.61.154.0/24,173.193.210.0/24

View File

@@ -1,3 +0,0 @@
node_modules
build
coverage/

View File

@@ -1,19 +0,0 @@
{
"mcpServers": {
"serena": {
"command": "uvx",
"args": [
"--from",
"git+https://github.com/oraios/serena",
"serena",
"start-mcp-server",
"--enable-web-dashboard",
"false"
]
},
"context7": {
"type": "http",
"url": "https://mcp.context7.com/mcp"
}
}
}

View File

@@ -1,61 +0,0 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Debug Server",
"type": "node",
"request": "launch",
"envFile": "${workspaceFolder}/.env",
"skipFiles": [
"<node_internals>/**"
],
"runtimeExecutable": "npm",
"runtimeArgs": [
"run-script",
"start"
],
"sourceMaps": true,
"env": {
"DEBUG": "*"
}
},
{
"type": "node",
"name": "vscode-jest-tests",
"request": "launch",
"envFile": "${workspaceFolder}/.env",
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen",
"cwd": "${workspaceFolder}",
"runtimeExecutable": "npm",
"args": [
"run",
"test",
"--",
"--runInBand",
"--watchAll=false"
]
},
{
"type": "node",
"name": "vscode-jest-tests-1634200842588",
"request": "launch",
"envFile": "${workspaceFolder}/.env",
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen",
"cwd": "${workspaceFolder}",
"runtimeExecutable": "npm",
"args": [
"run",
"test",
"--",
"--runInBand",
"--watchAll=false"
]
},
]
}

View File

@@ -1,9 +0,0 @@
{
"jest.jestCommandLine": "npm run test --",
"jest.autoRun": {
"watch": false,
"onSave": "test-file"
},
"jest.nodeEnv": {
}
}

View File

@@ -1,66 +0,0 @@
#--------------------------------------------
# Stage: building TypeScript
#--------------------------------------------
FROM node:20 AS build-stage
ENV WORKDIR=/app
WORKDIR /app
COPY ./package*.json ./
# instaliram pakete
RUN npm i && npm cache clean --force
COPY ./tsconfig.json ./
COPY ./src ./src
RUN npm run build
#--------------------------------------------
# Stage: installing production node_modules
#--------------------------------------------
FROM node:20 AS package-stage
WORKDIR /app
COPY ./package*.json ./
# install ONLY production dependencies
RUN npm i --omit=dev && npm cache clean --force
#--------------------------------------------
# Stage: preparing final image
#--------------------------------------------
FROM gcr.io/distroless/nodejs20-debian12:nonroot AS assembly-stage
WORKDIR /app
ARG PORT
ENV PORT=${PORT}
# (optional) App label to be used in Prometheus (Grafana)
ARG PROMETHEUS_APP_LABEL
ENV PROMETHEUS_APP_LABEL=${PROMETHEUS_APP_LABEL}=${PROMETHEUS_APP_LABEL}
# (optional) Prometheus histogram bucket sizes (grafana)
ARG PROMETHEUS_HISTOGRAM_BUCKETS
ENV PROMETHEUS_HISTOGRAM_BUCKETS=${PROMETHEUS_HISTOGRAM_BUCKETS}=${PROMETHEUS_HISTOGRAM_BUCKETS}
# (optional) enables logging to stdout
ARG DEBUG
ENV DEBUG=${DEBUG}
# copying node_modules
COPY --from=package-stage /app/package*.json ./
COPY --from=package-stage /app/node_modules ./node_modules
# copying built files
COPY --from=build-stage /app/build ./server
# running the server under limited "nobody" user
USER nobody:nobody
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s \
CMD ["/nodejs/bin/node", "./server/healthcheck.js"]
# starting the server
CMD ["./server/entry.js"]

View File

@@ -1,185 +1,23 @@
# MailGun Webhook Service
# Mailgun Webhook Handler
A production-ready TypeScript/Express.js service for receiving and logging MailGun webhook events with:
- **Webhook Processing**: Handles all MailGun email event types (delivered, failed, opened, clicked, etc.)
- **Structured Logging**: Console logging with detailed event information
- **Monitoring**: Built-in Prometheus metrics and health checks
- **Testing**: Complete Jest test suite with comprehensive webhook event coverage
- **Docker Ready**: Containerized deployment with health monitoring
This workspace contains the Mailgun webhook handler service for processing email events related to the Evidencija Režija tenant notification system.
## Features
## Purpose
### 📧 MailGun Webhook Integration
- **Event Types**: Supports all MailGun webhook events:
- `delivered` - Email successfully delivered
- `failed` - Delivery failed (temporary or permanent)
- `opened` - Email opened by recipient
- `clicked` - Link clicked in email
- `bounced` - Email bounced back
- `complained` - Recipient marked as spam
- `unsubscribed` - Recipient unsubscribed
- **Data Logging**: Comprehensive console logging with structured formatting
- **Type Safety**: Full TypeScript definitions for all event types
This service handles email verification and status updates by:
- Detecting new tenant email addresses (EmailStatus.Unverified)
- Sending verification emails via Mailgun
- Updating email status to VerificationPending
- Processing webhook events from Mailgun (bounces, complaints, etc.)
### 🏗️ Infrastructure
- **TypeScript**: Full type safety and modern JavaScript features
- **Express.js**: Fast, minimalist web framework
- **Logging**: Structured logging with debug support and detailed event formatting
- **Health Checks**: Built-in `/ping` endpoint for monitoring
## Architecture
### 📊 Monitoring & DevOps
- **Prometheus Metrics**: Built-in metrics collection at `/metrics`
- **Docker**: Complete containerization setup
- **Source Maps**: Debugging support for production
- **Hot Reload**: Development server with auto-restart
This is a separate system from the Next.js web-app that communicates via the shared MongoDB database.
### 🧪 Testing & Quality
- **Jest**: Comprehensive unit tests for all webhook event types
- **TypeScript**: Full type coverage
- **Test Structure**: Mirror source structure for easy navigation
- **CI Ready**: Tests configured for continuous integration
## Setup
## Architecture Overview
TBD
```
src/
├── entry.ts # Application bootstrap
├── app.ts # Express app configuration
├── routes/
│ ├── webhookRouter.ts # MailGun webhook handler
│ ├── pingRouter.ts # Health check endpoint
│ ├── metricsRouter.ts # Prometheus metrics
│ └── errorRouter.ts # Error handling
├── middleware/
│ └── InitLocalsMiddleware.ts # Request context initialization
├── types/
│ ├── MailgunWebhookEvent.ts # MailGun event type definitions
│ └── enums/SupportedRoutes.ts # Route path constants
└── lib/
├── logger.ts # Structured logging utilities
└── metricsCounters.ts # Prometheus metrics definitions
```
## Environment Variables
**API Endpoints:**
- `POST /webhook` - Receives MailGun webhook events
- `GET /ping` - Health check endpoint
- `GET /metrics` - Prometheus metrics
For detailed API specification, see [docs/MAILGUN_WEBHOOK_API_SPEC.md](docs/MAILGUN_WEBHOOK_API_SPEC.md).
## Quick Start
### 1. Installation
```bash
# Install dependencies
npm install
# Create environment file
cp .env.example .env
# Edit .env if needed (defaults work for development)
```
### 2. Development
```bash
# Start development server with hot reload
npm start
# Server will be running at http://localhost:3000
```
### 3. Testing the Webhook
```bash
# Send a test webhook event
curl -X POST http://localhost:3000/webhook \
-H "Content-Type: application/json" \
-d '{
"event": "delivered",
"timestamp": "1234567890",
"token": "test-token",
"signature": "test-signature",
"recipient": "user@example.com",
"domain": "mail.example.com"
}'
# Check the console output for logged event data
```
### 4. Running Tests
```bash
# Run tests in watch mode
npm test
# Run tests once with coverage
npm run test:ci
# Type checking
npm run type-check
```
### 5. Production Build
```bash
# Build TypeScript to JavaScript
npm run build
# Run production server
npm run run-server
```
## Configuring MailGun
To start receiving webhook events from MailGun:
1. **Log in to your MailGun account**
2. **Navigate to Sending → Webhooks**
3. **Add a new webhook URL**: `https://your-domain.com/webhook`
4. **Select event types** you want to receive (or select all)
5. **Save the webhook configuration**
MailGun will start sending events to your service endpoint immediately.
### Webhook Security (Future Enhancement)
For production deployment, you should implement webhook signature verification:
- Use MailGun's `timestamp`, `token`, and `signature` fields
- Verify the signature using your MailGun signing key
- See [MailGun Webhook Security Documentation](https://documentation.mailgun.com/en/latest/user_manual.html#webhooks)
## Docker Deployment
### Building the Docker Image
```bash
# Build the image
./build-image.sh 1.0.0
# The image will be tagged as mailgun-webhook-service:1.0.0
```
### Running with Docker
```bash
# Run the container
docker run -d \
-p 3000:3000 \
-e PORT=3000 \
-e PROMETHEUS_APP_LABEL=mailgun-webhook-service \
--name mailgun-webhook \
mailgun-webhook-service:1.0.0
# Check logs
docker logs -f mailgun-webhook
```
## Monitoring
The service exposes several monitoring endpoints:
- **Health Check**: `GET /ping` - Returns "pong" if service is healthy
- **Prometheus Metrics**: `GET /metrics` - Prometheus-compatible metrics
## Documentation
- 📧 **[MailGun Webhook API Spec](docs/MAILGUN_WEBHOOK_API_SPEC.md)** - Complete API specification
## License
ISC
TBD

View File

@@ -1,70 +0,0 @@
#!/bin/bash
# Parse flags
AUTO_VERSION=false
AUTO_PUSH=false
IMAGE_VERSION=""
for arg in "$@"; do
case $arg in
--auto-version)
AUTO_VERSION=true
;;
--auto-push)
AUTO_PUSH=true
;;
*)
if [ "$IMAGE_VERSION" == "" ] && [[ ! "$arg" =~ ^-- ]]; then
IMAGE_VERSION=$arg
fi
;;
esac
done
# Determine version
if [ "$AUTO_VERSION" = true ]; then
IMAGE_VERSION=$(node -p "require('./package.json').version")
printf "\nAuto-version enabled. Using version from package.json: %s\n" "$IMAGE_VERSION"
elif [ "$IMAGE_VERSION" == "" ]; then
printf "\nYou did not specify the Docker image version to build"
printf "\n\nSyntax:\n\n build-image.sh <version> [--auto-push]"
printf "\n build-image.sh --auto-version [--auto-push]\n\n"
exit 1
fi
REGISTRY_URL="registry.budakova.org"
IMAGE_NAME=$(node -p "require('./package.json').name")
IMAGE_TAG=$REGISTRY_URL/$IMAGE_NAME:$IMAGE_VERSION
# Check if image already exists in registry (only when using auto-version)
if [ "$AUTO_VERSION" = true ]; then
printf "\nChecking if image %s already exists in registry...\n" "$IMAGE_TAG"
if docker manifest inspect $IMAGE_TAG > /dev/null 2>&1; then
printf "\nERROR: Image %s already exists in registry.\n" "$IMAGE_TAG"
printf "Please update the version in package.json before building.\n\n"
exit 1
fi
printf "Image does not exist in registry. Proceeding with build.\n"
fi
# Check for push preference
if [ "$AUTO_PUSH" = true ]; then
PUSH_IMAGE="y"
printf "\nAuto-push enabled. Image will be pushed to registry.\n"
else
read -p "BUILD: Push new image to registry [y/n]? " -n 1 -r
echo # (optional) move to a new line
PUSH_IMAGE="$REPLY"
fi
printf "\nBUILD START ...\n\n"
docker build . -t $IMAGE_TAG
if [[ "$PUSH_IMAGE" =~ ^[Yy]$ ]]
then
printf "\nPushing image ...\n\n"
docker push $IMAGE_TAG
fi
printf "\nBUILD DONE!\n\n"

View File

@@ -1,176 +0,0 @@
# MailGun Webhook API Specification
## Overview
This document specifies the API for receiving webhook events from MailGun. The service logs all received event data to the console for monitoring and debugging purposes.
## API Endpoints
### POST /webhook
Receives webhook events from MailGun when email events occur.
#### Request Format
- **Method**: POST
- **Content-Type**: `application/x-www-form-urlencoded` or `multipart/form-data`
- **Headers**:
- No custom headers required for initial implementation
#### Request Parameters
MailGun sends various parameters depending on the event type. Common parameters include:
**Event Identification:**
- `event` (string) - Type of event (delivered, failed, opened, clicked, bounced, complained, unsubscribed)
- `timestamp` (number) - Unix timestamp when the event occurred
- `token` (string) - Randomly generated string for message signature verification
- `signature` (string) - String with hexadecimal digits for signature verification
**Message Information:**
- `message-id` (string) - MailGun message ID
- `recipient` (string) - Email address of the recipient
- `domain` (string) - Domain from which the email was sent
- `Message-Id` (string) - SMTP Message-ID header
**Event-Specific Parameters:**
For **delivered** events:
- `message-headers` (string) - JSON string of message headers
For **failed** events:
- `severity` (string) - Severity level (temporary/permanent)
- `reason` (string) - Reason for failure
- `notification` (string) - Detailed notification message
For **opened** events:
- `city` (string) - City where email was opened
- `country` (string) - Country code
- `device-type` (string) - Device type (desktop/mobile/tablet)
- `client-os` (string) - Operating system
- `client-name` (string) - Email client name
- `ip` (string) - IP address
For **clicked** events:
- `url` (string) - URL that was clicked
- `city`, `country`, `device-type`, `client-os`, `client-name`, `ip` - Same as opened events
For **bounced** events:
- `code` (string) - SMTP error code
- `error` (string) - Detailed error message
- `notification` (string) - Bounce notification
For **complained** events:
- No additional parameters
For **unsubscribed** events:
- No additional parameters
#### Success Response
- **HTTP Status**: 200 OK
- **Content-Type**: `application/json`
- **Response Body**:
```json
{
"status": "received",
"message": "Webhook event logged successfully"
}
```
#### Error Responses
**Invalid Request (400 Bad Request)**:
- **Content-Type**: `application/json`
- **Response Body**:
```json
{
"error": "Invalid request format"
}
```
**Server Error (500 Internal Server Error)**:
- **Content-Type**: `application/json`
- **Response Body**:
```json
{
"error": "Internal server error"
}
```
## Execution Flow
1. **Receive webhook POST request** from MailGun
2. **Parse request body** (form-urlencoded or multipart data)
3. **Extract event data** from request parameters
4. **Log event data to console** with structured formatting:
- Event type
- Timestamp (both Unix and human-readable)
- Recipient
- All additional event-specific parameters
5. **Return success response** to MailGun
## Edge Cases
### Missing Event Type
- **Detection**: Check if `event` parameter is present
- **Handling**: Log warning and return 400 Bad Request
### Malformed Timestamp
- **Detection**: Check if `timestamp` can be parsed as number
- **Handling**: Log with current timestamp instead, continue processing
### Large Payload
- **Detection**: Monitor request body size
- **Handling**: Log truncated data if exceeds reasonable size
### Duplicate Events
- **Detection**: MailGun may send duplicate webhooks
- **Handling**: Log all events (no deduplication in initial implementation)
## Security Considerations
### Future Enhancements
For production deployment, consider:
- **Signature Verification**: Verify webhook authenticity using `timestamp`, `token`, and `signature`
- **IP Whitelisting**: Restrict to MailGun's IP ranges (implemented via CloudFlare)
- **Rate Limiting**: Prevent abuse
## Database Integration
- **Current Implementation**: No database operations required
- **Future Enhancement**: Store events in database for analysis
## Third-Party API Calls
- **Current Implementation**: No third-party API calls
- **Future Enhancement**: Could integrate with notification services
## Logging Format
Console output format:
```
========================================
MailGun Webhook Event Received
========================================
Event Type: delivered
Timestamp: 1234567890 (2024-01-01 12:00:00 UTC)
Recipient: user@example.com
Domain: mail.example.com
Message ID: &lt;20240101120000.1.ABC123@mail.example.com&gt;
----------------------------------------
Additional Parameters:
{
"message-headers": "[...]",
"token": "...",
"signature": "..."
}
========================================
```
## Implementation Notes
- Use Express body-parser middleware for form data parsing
- All logging should use structured logger (debug package)
- Maintain type safety with TypeScript interfaces for event data
- Follow template's error handling patterns

View File

@@ -1,38 +0,0 @@
/** @type {import('jest/dist/types').InitialOptionsTsJest} */
import type { Config } from 'jest/build/index';
const config:Config = {
// preset: 'ts-jest',
transform: {
'^.+\\.tsx?$': [
'esbuild-jest', {
sourcemap:true, // bez ovog VS code umjesto originala prikazuje transpilirane datoteke
target:'es2020' // ovo je nužno kako bi BigInt funkcionirao
}]
},
maxWorkers: 4,
testEnvironment: 'node',
moduleNameMapper: {
},
// The root directory that Jest should scan for tests and modules within
rootDir: "./",
// A list of paths to directories that Jest should use to search for files in
roots: [
"<rootDir>/tests",
],
// The glob patterns Jest uses to detect test files
testMatch: [
"**/?(*.)+(spec).[tj]s?(x)",
],
// Automatically clear mock calls and instances between every test
clearMocks: true,
// An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation
transformIgnorePatterns: ["/node_modules/"],
// An array of regexp pattern strings that are matched against all test paths, matched tests are skipped
testPathIgnorePatterns: ["/node_modules/", "/build/"],
// Indicates whether each individual test should be reported during the run
verbose: true,
};
module.exports = config;

File diff suppressed because it is too large Load Diff

View File

@@ -1,51 +0,0 @@
{
"name": "mailgun-webhook-service",
"version": "1.0.1",
"description": "MailGun webhook receiver service for logging email event notifications",
"main": "entry.ts",
"scripts": {
"start": "nodemon ./src/entry.ts",
"build": "ttsc --project ./",
"test": "jest --watch",
"test:ci": "jest --ci --coverage --watchAll=false",
"test:coverage": "jest --coverage",
"lint": "tsc --noEmit",
"type-check": "tsc --noEmit",
"run-server": "DEBUG=* node --enable-source-maps ./build/entry.js"
},
"author": "Nikola",
"license": "ISC",
"dependencies": {
"debug": "^2.6.9",
"express": "^4.18.2",
"http-errors": "^1.7.2",
"prom-client": "^14.0.1",
"stoppable": "^1.1.0"
},
"devDependencies": {
"@babel/preset-typescript": "^7.18.6",
"@types/debug": "^4.1.7",
"@types/express": "^4.17.13",
"@types/http-errors": "^1.8.1",
"@types/jest": "^29.2.5",
"@types/node": "^16.10.2",
"@types/stoppable": "^1.1.1",
"@types/supertest": "^2.0.11",
"@zerollup/ts-transform-paths": "^1.7.18",
"esbuild": "^0.16.14",
"esbuild-jest": "^0.5.0",
"jest": "^29.3.1",
"nodemon": "^2.0.13",
"supertest": "^6.3.3",
"ts-node": "^10.9.1",
"tsconfig-paths": "^4.1.2",
"ttypescript": "^1.5.15",
"typescript": "^4.9.4",
"typescript-transform-paths": "^3.4.4"
},
"babel": {
"presets": [
"@babel/preset-typescript"
]
}
}

View File

@@ -1,49 +0,0 @@
import express from 'express';
import createError from 'http-errors';
import { errorRouter } from './routes/errorRouter';
import { finalErrorRouter } from './routes/finalErrorRouter';
import { metricsRouter } from './routes/metricsRouter';
import { SupportedRoutes } from './types/enums/SupportedRoutes';
import { pingRouter } from './routes/pingRouter';
import { InitLocalsMiddleware } from './middleware/InitLocalsMiddleware';
import { webhookRouter } from './routes/webhookRouter';
const app = express();
app.disable('x-powered-by');
// in case the server runs behind a proxy
// this flag will force Express to get information such as
// client IP address, protocol from X-Forward-*
// HTTP header fields, which are set by the proxy
app.set('trust proxy', true);
// Parse URL-encoded bodies (for MailGun webhook form data)
app.use(express.urlencoded({ extended: true }));
// Parse JSON bodies (in case MailGun sends JSON)
app.use(express.json());
// Middleware that initializes Locals
app.use(InitLocalsMiddleware);
// MailGun webhook endpoint
app.use(SupportedRoutes.webhook, webhookRouter);
// prometheus fetches the latest valid statistics from this route
app.use(SupportedRoutes.metricsPath, metricsRouter);
app.use(SupportedRoutes.ping, pingRouter);
// default handler
app.use((req, res, next) => next(createError(404)));
// error handler for all expected errors
app.use(errorRouter);
// error router for unexpected errors
app.use(finalErrorRouter);
export default app;

View File

@@ -1,111 +0,0 @@
import app from './app';
import http from 'http';
import stoppable from 'stoppable';
import { logServer } from './lib/logger';
/**
* Normalize a port into a number, string, or false.
*/
const normalizePort = (val:string):string|number|boolean => {
const port = parseInt(val, 10);
if (isNaN(port)) {
// named pipe
return val;
}
if (port >= 0) {
// port number
return port;
}
return false;
};
/**
* Event listener for HTTP server "error" event.
*/
const onError = (error:any):void => {
if (error.syscall !== 'listen') {
throw error;
}
const bind = typeof port === 'string'
? 'Pipe ' + port
: 'Port ' + port;
// handle specific listen errors with friendly messages
switch (error.code) {
case 'EACCES':
console.error(bind + ' requires elevated privileges');
process.exit(1);
break;
case 'EADDRINUSE':
console.error(bind + ' is already in use');
process.exit(1);
break;
default:
throw error;
}
};
/**
* Event listener for HTTP server "listening" event.
*/
const onListening = ():void => {
const addr = server.address();
const bind = typeof addr === 'string'
? 'pipe ' + addr
: 'port ' + addr?.port;
logServer(`server is running at ${bind}`);
};
/**
* Get port from environment and store in Express.
*/
const port:number|string|boolean = normalizePort(process.env.PORT || '3000');
/**
* How long should stoppable wait before it starts force-closing connections
* @description wait max 10 seconds - needs to be shorter than `healthcheck.timeout` (=15sec)
*/
const FORCE_STOP_TIMEOUT = 10000;
/**
* Create HTTP server.
*/
const server = stoppable( http.createServer(app), FORCE_STOP_TIMEOUT );
// Listen on provided port, on all network interfaces.
server.listen(port);
server.on('error', onError);
server.on('listening', onListening);
// quit on ctrl-c when running docker in terminal
// (signal neće biti registriran ako je server pokrenuti via `npm` ili `nodemon` - mora biti pokrenuti izravno via Node)
process.on('SIGINT', () => {
logServer('got SIGINT (aka ctrl-c in docker). Graceful shutdown ', new Date().toISOString());
shutdown();
});
// quit properly on docker stop
// (signal neće biti registriran ako je server pokrenuti via `npm` ili `nodemon` - mora biti pokrenuti izravno via Node)
process.on('SIGTERM', () => {
logServer('got SIGTERM (docker container stop). Graceful shutdown ', new Date().toISOString());
shutdown();
});
// shut down server
const shutdown = () => {
// NOTE: server.close is for express based apps
// If using hapi, use `server.stop`
server.close((err) => {
if (err) {
console.error(err);
process.exitCode = 1;
} else {
logServer('Exiting server process...');
}
process.exit();
});
};

View File

@@ -1,28 +0,0 @@
import { logHealthCheck } from "./lib/logger";
import http, { IncomingMessage } from "http";
const options = {
host: "localhost",
port: "3000",
timeout: 2000,
path: '/ping/'
};
const request = http.request(options, (res:IncomingMessage) => {
logHealthCheck(`STATUS ${res.statusCode}`);
if (res.statusCode == 200) {
process.exit(0);
} else {
process.exit(1);
}
});
request.on("error", function (err:any) {
logHealthCheck("ERROR");
process.exit(1);
});
request.end();

View File

@@ -1,38 +0,0 @@
import { Request } from 'express';
/**
* Determines the client's IP address
* @param req HTTP request object
* @returns client's IP address
*/
export const getClientIP = (req: Request):string => {
// CloudFlare performs NAT and forwards origin IP address in HTTP header
const CF_Connecting_IP:string|undefined|string[] = req.headers["cf-connecting-ip"];
// 1. priority = CloudFlare - handles connections from external clients
if(CF_Connecting_IP) {
return(CF_Connecting_IP as string);
}
// Fortigate gateway/SSL offloader do NAT, so original client IP address is
// not directly available. Instead it is forwarded via a HTTP header
const X_Forwarded_For:string|undefined|string[] = req.headers["x-forwarded-for"];
// 2. priority = X_Forwarded_For (added by Fortigate gateway)
// handles case when the traffic is not going through CF (e.g. local traffic going through FGT load balancer)
if(X_Forwarded_For) {
return(X_Forwarded_For as string);
}
// IP address of the client connected to the express server (e.g. reverse proxy or dev machine)
const direct_client_IP:string = req.ip as string;
// the local machine's IP address may be in IPv6 format
if(direct_client_IP.substr(0,7) === '::ffff:') {
return(direct_client_IP.substr(7)); // vraćam IPv4 adresu
}
// 3. priority = direct_client_IP - useful for local testing on DEV machine
return(direct_client_IP);
};

View File

@@ -1,8 +0,0 @@
/**
* Returns the provided value if it's defined and not empty, otherwise returns the default value.
*
* @param value - The value to check (can be string or undefined)
* @param defaultValue - The default value to return if value is undefined or empty string
* @returns The original value if it's defined and not empty, otherwise the default value
*/
export const coalesce = (value:string|undefined, defaultValue:string):string => value===undefined ? defaultValue : (value==="" ? defaultValue : value);

View File

@@ -1,68 +0,0 @@
import debug from 'debug';
/**
* Creates a debug logger instance with nodemon compatibility.
*
* @param namespace - Logger namespace for filtering output
* @returns Debug logger instance configured for the environment
*/
const createLogger = (namespace:string):debug.Debugger => {
const dbg = debug(namespace);
const nodemonRegex = /nodemon/gi;
if(nodemonRegex.test(process.env?.npm_lifecycle_script ?? "")) {
// When running via nodemon, force console output instead of stdout
// This ensures logs are visible since nodemon doesn't handle stdout properly
dbg.log = console.log.bind(console);
}
return(dbg);
};
const serverLogger = createLogger('server:server');
const healthCheckLogger = createLogger('server:healthcheck');
const errorLogger = createLogger('app:error');
const warnLogger = createLogger('app:warn');
const infoLogger = createLogger('app:info');
/**
* Logs a server message with server icon prefix.
* @param logTitle - The main server message
* @param logData - Optional additional data to log
* @returns
*/
export const logServer = (logTitle:string, logData:string|null=null) => serverLogger(`⚡️ SERVER: ${logTitle}`, logData);
/**
* Logs a health check message with health check icon prefix.
* @param logTitle - The main health check message
* @param logData - Optional additional data to log
* @returns
*/
export const logHealthCheck = (logTitle:string, logData:string|null=null) => healthCheckLogger(`🩺 HEALTHCHECK: ${logTitle}`, logData);
/**
* Logs an error message with error icon prefix.
*
* @param logTitle - The main error message
* @param logData - Optional additional data to log
*/
export const logError = (logTitle:string, logData:string|null=null) => errorLogger(`❌ ERROR: ${logTitle}`, logData);
/**
* Logs a warning message with warning icon prefix.
*
* @param logTitle - The main warning message
* @param logData - Optional additional data to log
*/
export const logWarn = (logTitle:string, logData:string|null=null) => warnLogger(`⚠️ WARN: ${logTitle}`, logData);
/**
* Logs an informational message with info icon prefix.
*
* @param logTitle - The main info message
* @param logData - Optional additional data to log
*/
export const logInfo = (logTitle:string, logData:string|null=null) => infoLogger(`🛈 INFO: ${logTitle}`, logData);

View File

@@ -1,60 +0,0 @@
import { Counter, Histogram, register } from 'prom-client';
import { coalesce } from './initTools';
const { name:packageName } = require('../../package.json');
/** Histogram Buckets */
const PROMETHEUS_HISTOGRAM_BUCKETS = coalesce(process.env.PROMETHEUS_HISTOGRAM_BUCKETS, '0.1,0.5,1,5,10');
/** Label to identify metrics collected from this web service */
const PROMETHEUS_APP_LABEL = coalesce(process.env.PROMETHEUS_APP_LABEL, packageName);
// We use "app" labels to separate results in Grafana
register.setDefaultLabels({ app: PROMETHEUS_APP_LABEL });
/**
* Counts total number of requests received for processing
*/
export const totalRequestCounter = new Counter({
name: "request_operations_total",
help: "total number of received requests",
/** Separate counters by request type */
labelNames: ['path'],
});
/**
* Counts requests that were successfully processed
*/
export const successfulRequestCounter = new Counter({
name: "request_operations_ok",
help: "number of successfully processed requests",
/** Separate counters by request type */
labelNames: ['path'],
});
/**
* Counts requests that encountered an error during processing
*/
export const failedRequestCounter = new Counter({
name: "request_operations_failed",
help: "number of requests that encountered an error during processing",
/** Separate counters by request type and execution result */
labelNames: ["path", "status"],
});
/**
* Counts requests that were rejected (e.g. by validation)
*/
export const rejectedRequestCounter = new Counter({
name: "request_operations_rejected",
help: "number of requests that were rejected",
labelNames: ["path", "status"],
});
/** Histogram measures how long incoming request processing takes */
export const requestDurationHistogram = new Histogram({
name: "request_duration_seconds",
help: "Request duration in seconds",
/** Separate counters by request type and execution result */
labelNames: ["path", "status"],
buckets: PROMETHEUS_HISTOGRAM_BUCKETS?.split(',').map((el) => parseFloat(el))
});

View File

@@ -1,43 +0,0 @@
import { Response, Request, NextFunction } from 'express';
import { AppLocals } from '../types/AppLocals';
import { requestDurationHistogram, totalRequestCounter } from '../lib/metricsCounters';
import { SupportedRoutes } from '../types/enums/SupportedRoutes';
/**
* Middleware initializes infrastructure objects which will be used throughout the request lifecycle
* @param req - Express request object
* @param res - Express response object
* @param next - Express next middleware function
*/
export const InitLocalsMiddleware = (req: Request, res: Response, next: NextFunction) => {
try {
switch(req.path) {
// for metrics routes, no prometheus timer is needed
case SupportedRoutes.metricsPath:
case '/favicon.ico':
// placeholder method to avoid checking if timer is initialized
(res.locals as AppLocals).stopPrometheusTimer = (labels) => 0;
break;
// all other routes get prometheus metrics
default:
// The request must be processed even if Prometheus does not work
// That's why here we wrap the Prometheus calls in try/catch
try {
// counting all received requests
totalRequestCounter.inc({ path: req.path });
// starting a timer to measure request processing duration
// this timer will be stopped in the route handler
(res.locals as AppLocals).stopPrometheusTimer = requestDurationHistogram.startTimer();
} catch(ex:any) {
console.error(ex);
}
break;
}
next();
} catch(ex:any) {
console.error('Error in InitLocalsMiddleware:', ex);
next(ex);
}
}

View File

@@ -1,97 +0,0 @@
import { ErrorRequestHandler, Request, Response } from "express";
import createHttpError, { HttpError } from "http-errors";
import { logError, logWarn } from '../lib/logger';
import { AppLocals } from "../types/AppLocals";
import { failedRequestCounter, rejectedRequestCounter } from "../lib/metricsCounters";
import { SupportedRoutes } from "../types/enums/SupportedRoutes";
/**
* Error handler that processes and formats error responses.
* Handles different error types, logs appropriately, and updates metrics.
*
* @param err - HTTP error object
* @param req - Express request object
* @param res - Express response object
* @param next - Express next function
*/
export const errorRouter:ErrorRequestHandler = async (err:HttpError, req, res, next) => {
const requestPath = req.path as SupportedRoutes;
// Since this error handler is complex, it might throw an error somewhere
// Wrap it in try-catch to ensure it won't crash the entire process
try {
let errorLogText:string = err.message,
errorLogName:string = err.name
const responseStatus:number = err.status;
let responseBody:string = "",
responseContentType = "text/html";
switch(err.status) {
case 400:
responseBody = 'bad request';
break;
case 401:
responseBody = 'unauthorized';
break;
case 403:
responseBody = 'forbidden';
break;
case 404:
logWarn(`page not found ${req.method} ${requestPath}`)
responseBody = 'page not found';
errorLogText = `page ${requestPath} not found`;
break;
case 500:
responseBody = "internal server error";
errorLogText = err.message;
break;
default:
responseBody = err.name;
errorLogText = `err.status=${err.status};err.name=${err.name};err.message=${err.message}`;
}
logWarn(`${errorLogName}:${errorLogText}`);
// `headersSent` will be TRUE if the router where the error occurred has already sent headers
// If we try to set them again, it will throw an error - we can avoid that here
if(!res.headersSent) {
res.status(responseStatus);
res.setHeader('Content-Type', responseContentType);
res.end(responseBody);
} else {
// If `end` hasn't been called - call it to finish processing the request
// Otherwise the connection will remain open until timeout
if(!res.writableEnded) {
res.end();
}
}
} catch(ex:any) {
// This error will be handled by `finalErrorRouter`
next(createHttpError(500, ex));
}
// Prevent prometheus client from crashing the server
try {
switch(err.status) {
case 400:
case 401:
case 403:
case 404:
// Count rejected requests separately from errors
rejectedRequestCounter.inc({ path: requestPath, status: err.status });
break;
case 500:
default:
failedRequestCounter.inc({ path: requestPath, status: err.status });
break;
}
(res.locals as AppLocals).stopPrometheusTimer({ path: req.path, status: err.status });
} catch(ex:any) {
logError(`Error while processing prometheus metrics: ${ex.message}`);
}
};

View File

@@ -1,33 +0,0 @@
import { ErrorRequestHandler, Request, Response } from "express";
import { HttpError } from "http-errors";
import { logError } from '../lib/logger';
/**
* Final error handler that executes when an unhandled error occurs.
* This prevents the server from crashing and ensures proper response handling.
*
* @param err - HTTP error object
* @param req - Express request object
* @param res - Express response object
* @param next - Express next function
*/
export const finalErrorRouter:ErrorRequestHandler = async (err:HttpError, req, res, next) => {
const errorLogText:string = JSON.stringify({ message:err.message, name:err.name, stack:err.stack });
logError("server error", `${err.status}; n${errorLogText}`);
// `headersSent` will be TRUE if the router where the error occurred has already sent headers
// If we try to set them again, it will throw an error and CRASH THE SERVER - we must prevent this
if(!res.headersSent) {
res.status(err.status);
res.setHeader('Content-Type', "text/html");
res.end(`unhandled server error`);
} else {
// If `end` hasn't been called - call it to finish processing the request
// Otherwise the connection will remain open until timeout
if(!res.writableEnded) {
res.end();
}
}
};

View File

@@ -1,19 +0,0 @@
import { Router, NextFunction, Request, Response } from "express";
import createError from 'http-errors';
import { register } from 'prom-client';
import { logServer } from '../lib/logger';
/** Express router for Prometheus metrics endpoint */
export const metricsRouter = Router();
metricsRouter.get('/', async (req:Request, res:Response, next:NextFunction) => {
// Prevent Prometheus client from crashing the server
// This is not mission critical, so we can afford it not working but cannot allow it to crash the server
try {
logServer(`GET /metrics`);
res.set('Content-Type', register.contentType);
res.end(await register.metrics());
} catch(ex:any) {
next(createError(500, (ex as Error).message));
}
});

View File

@@ -1,18 +0,0 @@
import { RequestHandler, Router } from "express";
/**
* Request handler that responds to health check requests.
*
* @param req - Express request object
* @param res - Express response object
* @param next - Express next function
*/
export const pingRequestHandler:RequestHandler = async (req, res, next) => {
res.status(200);
res.setHeader('Content-Type', 'text/plain');
res.end('PONG');
};
/** Express router for ping/health check endpoint */
export const pingRouter = Router();
pingRouter.get('/', pingRequestHandler);

View File

@@ -1,201 +0,0 @@
/**
* MailGun Webhook Router
*
* Handles incoming webhook events from MailGun and logs them to the console.
* This router processes POST requests containing email event data such as
* delivered, failed, opened, clicked, bounced, complained, and unsubscribed events.
*/
import { Router, Request, Response, NextFunction } from 'express';
import createError from 'http-errors';
import { AppLocals } from '../types/AppLocals';
import { isMailgunWebhookPayload, isValidMailgunEvent, MailgunWebhookEvent, MailgunWebhookPayloadSignature } from '../types/MailgunWebhookEvent';
import { successfulRequestCounter } from '../lib/metricsCounters';
import { logInfo, logError, logWarn } from '../lib/logger';
import crypto from 'crypto';
const WEBHOOK_SIGNING_KEY = process.env.MAILGUN_WEBHOOK_SIGNING_KEY;
if (!WEBHOOK_SIGNING_KEY) {
logError('Configuration Error', 'MAILGUN_WEBHOOK_SIGNING_KEY environment variable is not set');
throw new Error('MAILGUN_WEBHOOK_SIGNING_KEY environment variable is required');
}
/**
* Verifies the MailGun webhook signature
* @param param0 - Object containing signingKey, timestamp, token, and signature
* @returns boolean indicating if the signature is valid
*/
const verifySignature = ({ timestamp, token, signature }: MailgunWebhookPayloadSignature) => {
const encodedToken = crypto
.createHmac('sha256', WEBHOOK_SIGNING_KEY)
.update(timestamp.concat(token))
.digest('hex')
return (encodedToken === signature)
}
/**
* Formats a Unix timestamp into a human-readable date string
* @param timestamp - Unix timestamp as string
* @returns Formatted date string in ISO format
*/
const formatTimestamp = (timestamp: string): string => {
try {
const timestampNum = parseInt(timestamp, 10);
if (isNaN(timestampNum)) {
return 'Invalid timestamp';
}
return new Date(timestampNum * 1000).toISOString();
} catch (error) {
return 'Invalid timestamp';
}
};
/**
* Logs MailGun webhook event data to console with structured formatting
* @param eventData - The MailGun webhook event data
*/
const logWebhookEvent = (eventData: MailgunWebhookEvent): void => {
const separator = '========================================';
const minorSeparator = '----------------------------------------';
console.log('\n' + separator);
console.log('MailGun Webhook Event Received');
console.log(separator);
console.log(`Event Type: ${eventData.event}`);
console.log(`Timestamp: ${eventData.timestamp} (${formatTimestamp(eventData.timestamp)})`);
console.log(`Recipient: ${eventData.recipient}`);
console.log(`Domain: ${eventData.domain}`);
if (eventData['message-id']) {
console.log(`MailGun Message ID: ${eventData['message-id']}`);
}
if (eventData['Message-Id']) {
console.log(`SMTP Message ID: ${eventData['Message-Id']}`);
}
// Log event-specific data
console.log(minorSeparator);
console.log('Event-Specific Data:');
switch (eventData.event) {
case 'delivered':
if (eventData['message-headers']) {
console.log(`Message Headers: ${eventData['message-headers'].substring(0, 200)}...`);
}
break;
case 'failed':
if (eventData.severity) console.log(`Severity: ${eventData.severity}`);
if (eventData.reason) console.log(`Reason: ${eventData.reason}`);
if (eventData.notification) console.log(`Notification: ${eventData.notification}`);
break;
case 'opened':
if (eventData.city) console.log(`City: ${eventData.city}`);
if (eventData.country) console.log(`Country: ${eventData.country}`);
if (eventData['device-type']) console.log(`Device Type: ${eventData['device-type']}`);
if (eventData['client-os']) console.log(`Client OS: ${eventData['client-os']}`);
if (eventData['client-name']) console.log(`Client Name: ${eventData['client-name']}`);
if (eventData.ip) console.log(`IP Address: ${eventData.ip}`);
break;
case 'clicked':
console.log(`URL Clicked: ${eventData.url}`);
if (eventData.city) console.log(`City: ${eventData.city}`);
if (eventData.country) console.log(`Country: ${eventData.country}`);
if (eventData['device-type']) console.log(`Device Type: ${eventData['device-type']}`);
if (eventData['client-os']) console.log(`Client OS: ${eventData['client-os']}`);
if (eventData['client-name']) console.log(`Client Name: ${eventData['client-name']}`);
if (eventData.ip) console.log(`IP Address: ${eventData.ip}`);
break;
case 'bounced':
if (eventData.code) console.log(`SMTP Code: ${eventData.code}`);
if (eventData.error) console.log(`Error: ${eventData.error}`);
if (eventData.notification) console.log(`Notification: ${eventData.notification}`);
break;
case 'complained':
console.log('User marked email as spam');
break;
case 'unsubscribed':
console.log('User unsubscribed from mailing list');
break;
}
// Log full event data for debugging
console.log(minorSeparator);
console.log('Full Event Data (JSON):');
console.log(JSON.stringify(eventData, null, 2));
console.log(separator + '\n');
};
/**
* Main webhook request handler
* Processes incoming MailGun webhook events and logs them to console
*/
export const webhookRequestHandler = async (req: Request, res: Response, next: NextFunction) => {
try {
const payload = req.body as any;
if(!isMailgunWebhookPayload(payload)) {
logWarn('Invalid webhook payload structure', JSON.stringify(payload));
next(createError(400, 'Invalid request format: payload structure is incorrect'));
return;
}
const { signature, "event-data": eventData } = payload;
// Validate that we have the minimum required fields
if (!isValidMailgunEvent(eventData)) {
logWarn('Invalid webhook event received', JSON.stringify(eventData));
next(createError(400, 'Invalid request format: missing required fields (event, recipient)'));
return;
}
// Verify webhook signature
const isValidSignature = verifySignature({
timestamp: signature.timestamp,
token: signature.token,
signature: signature.signature
});
if(!isValidSignature) {
logWarn('Invalid webhook signature', JSON.stringify(signature));
next(createError(401, 'Unauthorized: invalid webhook signature'));
return;
}
// Log the webhook event to console
logWebhookEvent(eventData as MailgunWebhookEvent);
// Log using the structured logger as well
logInfo('MailGun webhook event processed', `Event: ${eventData.event}, Recipient: ${eventData.recipient}`);
// Return success response to MailGun
res.status(200).json({
status: 'received',
message: 'Webhook event logged successfully'
});
// Increment successful requests counter for metrics
successfulRequestCounter.inc();
} catch (ex: any) {
logError('Error processing webhook', ex.message);
next(createError(500, 'Internal server error'));
}
// Stop Prometheus timer
(res.locals as AppLocals).stopPrometheusTimer({ path: req.path });
};
/**
* Express router for MailGun webhook endpoint
*/
const router = Router();
export const webhookRouter = router.post('/', webhookRequestHandler);

View File

@@ -1,9 +0,0 @@
import { LabelValues } from "prom-client";
export type PrometheusTimer = (labels?: LabelValues<"path"|"status">) => number;
/** Data assigned to `express.response.locals` */
export type AppLocals = {
/** Prometheus timer function for stopping request duration measurement */
stopPrometheusTimer: PrometheusTimer,
};

View File

@@ -1,188 +0,0 @@
/**
* Type definitions for MailGun webhook events
*/
/**
* All possible MailGun webhook event types
*/
export type MailgunEventType =
| 'delivered'
| 'failed'
| 'opened'
| 'clicked'
| 'bounced'
| 'complained'
| 'unsubscribed';
export interface MailgunWebhookPayload {
signature: MailgunWebhookPayloadSignature;
"event-data": MailgunWebhookEvent;
}
export interface MailgunWebhookPayloadSignature {
token: string;
timestamp: string;
signature: string;
}
/**
* Base interface for all MailGun webhook events
* Contains fields common to all event types
*/
export interface MailgunWebhookEventBase {
/** Type of event */
event: MailgunEventType;
/** Unix timestamp when the event occurred */
timestamp: string;
/** Randomly generated string for message signature verification */
token: string;
/** String with hexadecimal digits for signature verification */
signature: string;
/** Email address of the recipient */
recipient: string;
/** Domain from which the email was sent */
domain: string;
/** MailGun message ID */
'message-id'?: string;
/** SMTP Message-ID header */
'Message-Id'?: string;
}
/**
* Additional fields for 'delivered' events
*/
export interface MailgunDeliveredEvent extends MailgunWebhookEventBase {
event: 'delivered';
/** JSON string of message headers */
'message-headers'?: string;
}
/**
* Additional fields for 'failed' events
*/
export interface MailgunFailedEvent extends MailgunWebhookEventBase {
event: 'failed';
/** Severity level (temporary/permanent) */
severity?: string;
/** Reason for failure */
reason?: string;
/** Detailed notification message */
notification?: string;
}
/**
* Additional fields for events with tracking data (opened, clicked)
*/
export interface MailgunTrackingData {
/** City where email was opened */
city?: string;
/** Country code */
country?: string;
/** Device type (desktop/mobile/tablet) */
'device-type'?: string;
/** Operating system */
'client-os'?: string;
/** Email client name */
'client-name'?: string;
/** IP address */
ip?: string;
}
/**
* Additional fields for 'opened' events
*/
export interface MailgunOpenedEvent extends MailgunWebhookEventBase, MailgunTrackingData {
event: 'opened';
}
/**
* Additional fields for 'clicked' events
*/
export interface MailgunClickedEvent extends MailgunWebhookEventBase, MailgunTrackingData {
event: 'clicked';
/** URL that was clicked */
url: string;
}
/**
* Additional fields for 'bounced' events
*/
export interface MailgunBouncedEvent extends MailgunWebhookEventBase {
event: 'bounced';
/** SMTP error code */
code?: string;
/** Detailed error message */
error?: string;
/** Bounce notification */
notification?: string;
}
/**
* Additional fields for 'complained' events
*/
export interface MailgunComplainedEvent extends MailgunWebhookEventBase {
event: 'complained';
}
/**
* Additional fields for 'unsubscribed' events
*/
export interface MailgunUnsubscribedEvent extends MailgunWebhookEventBase {
event: 'unsubscribed';
}
/**
* Union type of all possible MailGun webhook events
*/
export type MailgunWebhookEvent =
| MailgunDeliveredEvent
| MailgunFailedEvent
| MailgunOpenedEvent
| MailgunClickedEvent
| MailgunBouncedEvent
| MailgunComplainedEvent
| MailgunUnsubscribedEvent;
/**
* Type guard to check if event data has required fields
*/
export function isValidMailgunEvent(data: any): data is MailgunWebhookEventBase {
return (
data &&
typeof data === 'object' &&
typeof data.event === 'string' &&
typeof data.recipient === 'string'
);
}
/**
* Type guard to check if data is a MailgunWebhookPayload
*/
export function isMailgunWebhookPayload(data: any): data is MailgunWebhookPayload {
return (
data &&
typeof data === 'object' &&
typeof data.signature === 'object' &&
typeof data['event-data'] === 'object'
);
}

View File

@@ -1,8 +0,0 @@
export enum SupportedRoutes {
ping='/ping/',
metricsPath='/metrics',
/**
* MailGun webhook endpoint for receiving email event notifications
*/
webhook='/webhook',
}

View File

@@ -1,20 +0,0 @@
declare global {
namespace NodeJS {
interface ProcessEnv {
PORT?: string,
/**
* (optional) App label to be used in Prometheus (Grafana)
* @default "evo-open-table-sync-svc"
* */
PROMETHEUS_APP_LABEL?: string
/**
* (optional) Prometheus histogram bucket sizes (grafana)
* @default "0.1, 0.5, 1, 5, 10"
* */
PROMETHEUS_HISTOGRAM_BUCKETS?: string,
}
}
}
export {}

View File

@@ -1 +0,0 @@
export const randomUUID = () => 'mock-uuid-created-by-crypto';

View File

@@ -1,18 +0,0 @@
/**
* Mock implementation of the logger module for testing
*/
export const logServer = jest.fn();
export const logHealthCheck = jest.fn();
export const logError = jest.fn();
export const logWarn = jest.fn();
export const logInfo = jest.fn();
// Helper function to reset all mocks
export const resetAllLoggerMocks = () => {
logServer.mockClear();
logHealthCheck.mockClear();
logError.mockClear();
logWarn.mockClear();
logInfo.mockClear();
};

View File

@@ -1,29 +0,0 @@
import { LabelValues } from "prom-client";
export class Counter {
public inc() {
}
}
export class Histogram<T extends string> {
startTimer(labels?: LabelValues<T>): (labels?: LabelValues<T>) => void {
return((labels?: LabelValues<T>) => { });
}
}
class Register {
public setDefaultLabels(labels: Object) {
}
public metrics(): Promise<string> {
return(Promise.resolve(""));
}
public get contentType() {
return("");
}
}
export const register = new Register();

View File

@@ -1,59 +0,0 @@
/**
* @fileoverview
* This file is a part of an "auth" example router, which was taken from an existing integration.
* It is to be used only as a reference for how an API router for a web service should be structured.
* In a real-live implementation all files related to `/auth` route example should be removed
* by new set of files implementing the new API.
*/
import { Request, Response, NextFunction } from 'express';
import { AppLocals } from "../../src/types/AppLocals";
interface IMockHttpParams {
sessionID?: number
sessionGUID?: string
table_id?: string
clientIp?: string
free_play: 0 | 1
}
interface IMockHttpContext {
clientIpAddress?:string
reqPath?:string
headersSent?:boolean
writableEnded?:boolean
method?:string
params?: IMockHttpParams
}
export const defaultMockParams:IMockHttpParams = {
sessionID: 123,
sessionGUID: '016e6812-b915-4e5e-94fe-193582239b96',
table_id: 'mock-table-id',
clientIp: '192.168.1.10',
free_play: 0
}
export const mockHttpContext = ({reqPath="/", headersSent=false, writableEnded=false, method="GET", params=defaultMockParams}:IMockHttpContext|undefined = {}) => {
const req = {
path:reqPath,
method,
url:`https://localhost${reqPath}`,
params,
} as unknown as Request;
const res = {
end: jest.fn(),
status: jest.fn(),
setHeader: jest.fn(),
params,
locals: {
stopPrometheusTimer: jest.fn(),
} as unknown as AppLocals,
headersSent,
writableEnded,
} as unknown as Response;
const next:NextFunction = jest.fn();
return({req,res,next})
}

View File

@@ -1,115 +0,0 @@
/**
* Helper for creating mock MailGun webhook events for testing
*/
import {
MailgunWebhookEvent,
MailgunDeliveredEvent,
MailgunFailedEvent,
MailgunOpenedEvent,
MailgunClickedEvent,
MailgunBouncedEvent,
MailgunComplainedEvent,
MailgunUnsubscribedEvent
} from '../../src/types/MailgunWebhookEvent';
/**
* Base event data common to all webhook events
*/
const baseEventData = {
timestamp: '1234567890',
token: 'mock-token-123',
signature: 'mock-signature-abc123',
recipient: 'user@example.com',
domain: 'mail.example.com',
'message-id': '<20240101120000.1.ABC123@mail.example.com>',
'Message-Id': '<20240101120000.1.ABC123@mail.example.com>'
};
/**
* Creates a mock 'delivered' event
*/
export const createMockDeliveredEvent = (): MailgunDeliveredEvent => ({
...baseEventData,
event: 'delivered',
'message-headers': JSON.stringify([
['Subject', 'Test Email'],
['From', 'sender@example.com']
])
});
/**
* Creates a mock 'failed' event
*/
export const createMockFailedEvent = (): MailgunFailedEvent => ({
...baseEventData,
event: 'failed',
severity: 'permanent',
reason: 'bounce',
notification: 'User inbox is full'
});
/**
* Creates a mock 'opened' event
*/
export const createMockOpenedEvent = (): MailgunOpenedEvent => ({
...baseEventData,
event: 'opened',
city: 'San Francisco',
country: 'US',
'device-type': 'desktop',
'client-os': 'macOS',
'client-name': 'Apple Mail',
ip: '192.168.1.100'
});
/**
* Creates a mock 'clicked' event
*/
export const createMockClickedEvent = (): MailgunClickedEvent => ({
...baseEventData,
event: 'clicked',
url: 'https://example.com/link',
city: 'New York',
country: 'US',
'device-type': 'mobile',
'client-os': 'iOS',
'client-name': 'Gmail',
ip: '192.168.1.101'
});
/**
* Creates a mock 'bounced' event
*/
export const createMockBouncedEvent = (): MailgunBouncedEvent => ({
...baseEventData,
event: 'bounced',
code: '550',
error: 'User not found',
notification: 'The email account that you tried to reach does not exist'
});
/**
* Creates a mock 'complained' event
*/
export const createMockComplainedEvent = (): MailgunComplainedEvent => ({
...baseEventData,
event: 'complained'
});
/**
* Creates a mock 'unsubscribed' event
*/
export const createMockUnsubscribedEvent = (): MailgunUnsubscribedEvent => ({
...baseEventData,
event: 'unsubscribed'
});
/**
* Creates an invalid event missing required fields
*/
export const createInvalidEvent = () => ({
timestamp: '1234567890',
token: 'mock-token-123'
// Missing 'event' and 'recipient' fields
});

View File

@@ -1,120 +0,0 @@
import { errorRouter } from '../../src/routes/errorRouter';
import createError from "http-errors";
import { mockHttpContext } from "../helpers/mockHttpContext";
import { logWarn, resetAllLoggerMocks } from '../__mocks__/logger';
// Mock the logger module
jest.mock('../../src/lib/logger', () => require('../__mocks__/logger'));
describe("errorRouter", () => {
beforeEach(() => {
resetAllLoggerMocks();
});
test("should return string message 'page not found' in case of 404 error", async () => {
const err = createError(404)
const {req,res,next} = mockHttpContext();
await errorRouter(err, req, res, next);
expect(res.status).toHaveBeenCalledWith(404);
expect(res.setHeader).toHaveBeenCalledWith('Content-Type', "text/html");
expect(res.end).toHaveBeenCalledWith("page not found");
});
test("should log page not found warning in case of 404 error", async () => {
const err = createError(404)
const reqPath = "/some-path/";
const {req,res,next} = mockHttpContext({ reqPath });
await errorRouter(err, req, res, next);
expect(logWarn).toHaveBeenCalledWith(`page not found GET ${reqPath}`);
expect(logWarn).toHaveBeenCalledWith(`${err.name}:page ${reqPath} not found`);
});
test("should not send headers again if they are already sent", async () => {
const err = createError(404)
const {req,res,next} = mockHttpContext({ headersSent:true, writableEnded:true });
await errorRouter(err, req, res, next);
expect(res.status).not.toHaveBeenCalled();
expect(res.setHeader).not.toHaveBeenCalled();
expect(res.end).not.toHaveBeenCalled();
});
test("should call [end] method if it has NOT been called yet", async () => {
const err = createError(404)
const {req,res,next} = mockHttpContext({ headersSent:true, writableEnded:false });
await errorRouter(err, req, res, next);
expect(res.status).not.toHaveBeenCalled();
expect(res.setHeader).not.toHaveBeenCalled();
expect(res.end).toHaveBeenCalled();
});
test("should stop Prometheus Timer", async () => {
const err = createError(404)
const {req,res,next} = mockHttpContext({ headersSent:true, writableEnded:false });
await errorRouter(err, req, res, next);
expect(res.locals.stopPrometheusTimer).toHaveBeenCalled();
});
test("should return string message 'internal server error' in case of 500 error", async () => {
const err = createError(500)
const {req,res,next} = mockHttpContext();
await errorRouter(err, req, res, next);
expect(res.status).toHaveBeenCalledWith(500);
expect(res.setHeader).toHaveBeenCalledWith('Content-Type', "text/html");
expect(res.end).toHaveBeenCalledWith("internal server error");
});
test("should return string message 'bad request' and log error in case of 400 error", async () => {
const errorMessage = "mock error text 1";
const err = createError(400, errorMessage);
const {req,res,next} = mockHttpContext();
await errorRouter(err, req, res, next);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.setHeader).toHaveBeenCalledWith('Content-Type', "text/html");
expect(res.end).toHaveBeenCalledWith("bad request");
expect(logWarn).toHaveBeenCalledWith(`${err.name}:${errorMessage}`);
});
test("should return string message 'unauthorized' and log error in case of 401 error", async () => {
const errorMessage = "mock error text 2";
const err = createError(401, errorMessage)
const {req,res,next} = mockHttpContext();
await errorRouter(err, req, res, next);
expect(res.status).toHaveBeenCalledWith(401);
expect(res.setHeader).toHaveBeenCalledWith('Content-Type', "text/html");
expect(res.end).toHaveBeenCalledWith("unauthorized");
expect(logWarn).toHaveBeenCalledWith(`${err.name}:${errorMessage}`);
});
test("should return string message 'forbidden' and log error in case of 403 error", async () => {
const errorMessage = "mock error text 3";
const err = createError(403, errorMessage);
const {req,res,next} = mockHttpContext();
await errorRouter(err, req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.setHeader).toHaveBeenCalledWith('Content-Type', "text/html");
expect(res.end).toHaveBeenCalledWith("forbidden");
expect(logWarn).toHaveBeenCalledWith(`${err.name}:${errorMessage}`);
});
});

View File

@@ -1,196 +0,0 @@
/**
* Tests for MailGun webhook router
*/
import request from 'supertest';
import app from '../../src/app';
import {
createMockDeliveredEvent,
createMockFailedEvent,
createMockOpenedEvent,
createMockClickedEvent,
createMockBouncedEvent,
createMockComplainedEvent,
createMockUnsubscribedEvent,
createInvalidEvent
} from '../helpers/mockWebhookEvent';
describe('MailGun Webhook Router', () => {
// Mock console.log to avoid cluttering test output
let consoleLogSpy: jest.SpyInstance;
beforeEach(() => {
consoleLogSpy = jest.spyOn(console, 'log').mockImplementation();
});
afterEach(() => {
consoleLogSpy.mockRestore();
});
describe('POST /webhook', () => {
it('should handle delivered event successfully', async () => {
const event = createMockDeliveredEvent();
const response = await request(app)
.post('/webhook')
.send(event)
.expect(200);
expect(response.body).toEqual({
status: 'received',
message: 'Webhook event logged successfully'
});
// Verify console.log was called with event data
expect(consoleLogSpy).toHaveBeenCalled();
const logOutput = consoleLogSpy.mock.calls.join('\n');
expect(logOutput).toContain('MailGun Webhook Event Received');
expect(logOutput).toContain('Event Type: delivered');
expect(logOutput).toContain('Recipient: user@example.com');
});
it('should handle failed event successfully', async () => {
const event = createMockFailedEvent();
const response = await request(app)
.post('/webhook')
.send(event)
.expect(200);
expect(response.body).toEqual({
status: 'received',
message: 'Webhook event logged successfully'
});
const logOutput = consoleLogSpy.mock.calls.join('\n');
expect(logOutput).toContain('Event Type: failed');
expect(logOutput).toContain('Severity: permanent');
expect(logOutput).toContain('Reason: bounce');
});
it('should handle opened event successfully', async () => {
const event = createMockOpenedEvent();
const response = await request(app)
.post('/webhook')
.send(event)
.expect(200);
expect(response.body.status).toBe('received');
const logOutput = consoleLogSpy.mock.calls.join('\n');
expect(logOutput).toContain('Event Type: opened');
expect(logOutput).toContain('City: San Francisco');
expect(logOutput).toContain('Device Type: desktop');
});
it('should handle clicked event successfully', async () => {
const event = createMockClickedEvent();
const response = await request(app)
.post('/webhook')
.send(event)
.expect(200);
expect(response.body.status).toBe('received');
const logOutput = consoleLogSpy.mock.calls.join('\n');
expect(logOutput).toContain('Event Type: clicked');
expect(logOutput).toContain('URL Clicked: https://example.com/link');
});
it('should handle bounced event successfully', async () => {
const event = createMockBouncedEvent();
const response = await request(app)
.post('/webhook')
.send(event)
.expect(200);
expect(response.body.status).toBe('received');
const logOutput = consoleLogSpy.mock.calls.join('\n');
expect(logOutput).toContain('Event Type: bounced');
expect(logOutput).toContain('SMTP Code: 550');
});
it('should handle complained event successfully', async () => {
const event = createMockComplainedEvent();
const response = await request(app)
.post('/webhook')
.send(event)
.expect(200);
expect(response.body.status).toBe('received');
const logOutput = consoleLogSpy.mock.calls.join('\n');
expect(logOutput).toContain('Event Type: complained');
});
it('should handle unsubscribed event successfully', async () => {
const event = createMockUnsubscribedEvent();
const response = await request(app)
.post('/webhook')
.send(event)
.expect(200);
expect(response.body.status).toBe('received');
const logOutput = consoleLogSpy.mock.calls.join('\n');
expect(logOutput).toContain('Event Type: unsubscribed');
});
it('should return 400 for invalid event missing required fields', async () => {
const event = createInvalidEvent();
const response = await request(app)
.post('/webhook')
.send(event)
.expect(400);
// Error router returns plain text, not JSON
expect(response.text).toBe('bad request');
});
it('should handle URL-encoded form data (MailGun default format)', async () => {
const event = createMockDeliveredEvent();
const response = await request(app)
.post('/webhook')
.type('form')
.send(event)
.expect(200);
expect(response.body.status).toBe('received');
});
it('should log timestamp in human-readable format', async () => {
const event = createMockDeliveredEvent();
await request(app)
.post('/webhook')
.send(event)
.expect(200);
const logOutput = consoleLogSpy.mock.calls.join('\n');
expect(logOutput).toContain('Timestamp: 1234567890');
// Check that ISO format date is logged
expect(logOutput).toMatch(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/);
});
it('should log full event data as JSON', async () => {
const event = createMockDeliveredEvent();
await request(app)
.post('/webhook')
.send(event)
.expect(200);
const logOutput = consoleLogSpy.mock.calls.join('\n');
expect(logOutput).toContain('Full Event Data (JSON)');
expect(logOutput).toContain('"event": "delivered"');
});
});
});

View File

@@ -1,34 +0,0 @@
{
"compilerOptions": {
"target": "es2020", // https://github.com/microsoft/TypeScript/wiki/Node-Target-Mapping
"module": "commonjs",
"esModuleInterop": true, // solves the problem regarding the default importa vs importa *
"strict": true,
"sourceMap": true, // please do create source maps
"skipLibCheck": true, // don't verify typescript of 3rd party modules
"rootDir": "src", // root directory under which source files are located - it's subtree will be mirrored in "outDir"
"outDir": "build", // where the build files should be stored
// "baseUrl" ----- DO NOT USE ... heres why:
// NOTE: if "baseUrl" is set then Intellisense while doing autocompletion (Ctrl+Space)
// will use and insert absolute module path instead of relative one,
// which will make the build fail
// "baseUrl": "./", // set a base directory to resolve non-absolute module names - This must be specified if "paths" is used
"plugins": [
{
// The following is used for when building the project
// NOTE: build is done by `ttypescript`
// which does not know how to interpret what is set in "paths"
// > this problem is fixed by "typescript-transform-paths"
"transform": "typescript-transform-paths"
}
]
},
"include": ["src/**/*"], // location of files which need to be compiled
// The following is used for debugging the server in VS Code
// NOTE: when debugging the module is started using `ts-node`,
// which does not know how to interpret what is set in "paths"
// > this is fixed by "tsconfig-paths/register"
"ts-node": {
"require": ["tsconfig-paths/register"]
},
}

16888
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,26 +0,0 @@
{
"name": "evidencija-rezija-monorepo",
"version": "1.0.0",
"description": "Property management and utility bills tracking monorepo",
"private": true,
"workspaces": [
"web-app",
"email-worker",
"shared-code",
"mailgun-webhook"
],
"scripts": {
"install:all": "npm install",
"build:web-app": "npm run build --workspace=web-app",
"build:email-worker": "npm run build --workspace=email-worker",
"dev:web-app": "npm run dev --workspace=web-app",
"dev:email-worker": "npm run start --workspace=email-worker"
},
"keywords": [
"property-management",
"utility-bills",
"monorepo"
],
"author": "",
"license": "ISC"
}

View File

@@ -1,14 +0,0 @@
{
"name": "@evidencija-rezija/shared-code",
"version": "1.0.0",
"description": "Shared code for web-app and email-worker",
"main": "src/index.ts",
"types": "src/index.ts",
"scripts": {},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"mongodb": "^6.0.0"
}
}

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