diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 3f9cead..f10d589 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -20,7 +20,11 @@ "mcp__context7__resolve-library-id", "mcp__context7__get-library-docs", "mcp__serena__create_text_file", - "Bash(curl:*)" + "Bash(curl:*)", + "Bash(git mv:*)", + "Bash(rmdir:*)", + "Bash(mkdir:*)", + "Bash(git diff:*)" ] }, "enableAllProjectMcpServers": true, diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml new file mode 100644 index 0000000..21df4a0 --- /dev/null +++ b/.gitea/workflows/build.yml @@ -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 diff --git a/.gitea/workflows/check_image_version.yml b/.gitea/workflows/check_image_version.yml new file mode 100644 index 0000000..f9033c7 --- /dev/null +++ b/.gitea/workflows/check_image_version.yml @@ -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 }}" diff --git a/.gitea/workflows/check_package_version.yml b/.gitea/workflows/check_package_version.yml new file mode 100644 index 0000000..ac2b474 --- /dev/null +++ b/.gitea/workflows/check_package_version.yml @@ -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 diff --git a/.gitignore b/.gitignore index 2f2e78b..aa3a3b1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,14 +1,16 @@ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # dependencies -/node_modules +node_modules /.pnp .pnp.js # testing /coverage -# next.js +# next.js (in web-app workspace) +web-app/.next/ +web-app/out/ /.next/ /out/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 3996f0f..d7cb7d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,24 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Changed +- **Repository Structure**: Converted to multi-project monorepo + - `web-app/` - Main Next.js application (formerly root directory) + - `housekeeping/` - Database backup and maintenance scripts +- **Docker Configuration**: Updated Dockerfile and docker-compose files for new directory structure +- **Documentation**: Updated README.md and CLAUDE.md to reflect new structure + +### Migration Notes +- All application code moved to `web-app/` directory using `git mv` to preserve history +- All database backup scripts moved to `housekeeping/` directory +- Each project is self-contained with its own package.json and dependencies +- Docker builds install dependencies from `web-app/` directory +- Volume mounts in docker-compose updated to reference `web-app/etc/hosts/` +- `.gitignore` updated to handle `node_modules` at any directory level +- No workspace management - each project is completely independent + ## [2.17.0] - 2025-12-21 ### Changed diff --git a/CLAUDE.md b/CLAUDE.md index bcaa9d7..0e824de 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,20 +2,41 @@ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. +## Repository Structure + +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 + +Each project is self-contained with its own dependencies. + ## Development Commands -- `npm run dev` - Start development server (Next.js) -- `npm run build` - Build production version +All commands should be run from within the respective project directory. + +**Web App** (`cd web-app`): +- `npm install` - Install dependencies +- `npm run dev` - Start development server +- `npm run build` - Build production version - `npm start` - Start production server -- `npm run prettier` - Format code with Prettier -- `npm run prettier:check` - Check code formatting +- `npm run prettier` - Format code - `npm run seed` - Seed database with initial data +**Housekeeping** (`cd housekeeping`): +- `./db-backup--standalone.sh` - Run standalone database backup +- `./db-backup--swarm.sh` - Run swarm database backup +- `./db-dump--standalone.sh` - Run standalone database dump +- See housekeeping/README.md for more details + ## Deployment Commands -- `./build.sh` - Build Docker image for deployment -- `./deploy.sh` - Deploy Docker service to production -- `./debug-deploy.sh` - Deploy with debug configuration +**Building Docker Image** (`cd web-app`): +- `./build.sh ` - Build Docker image + +**Deploying** (`cd docker-stack`): +- `./deploy-standalone.sh ` - Deploy with docker-compose (standalone) +- `./deploy-swarm.sh ` - Deploy with Docker Swarm ## Architecture Overview @@ -31,25 +52,26 @@ This is a Next.js 14 utility bills tracking application ("Evidencija Režija") w ### Core Architecture Patterns -**Multi-user Data Isolation**: All database operations use the `withUser` higher-order function from `app/lib/auth.ts:102` to automatically inject authenticated user ID into queries, ensuring data isolation between users. +**Multi-user Data Isolation**: All database operations use the `withUser` higher-order function from `web-app/app/lib/auth.ts:102` to automatically inject authenticated user ID into queries, ensuring data isolation between users. -**Server Actions Pattern**: Form handling uses Next.js Server Actions with Zod validation. Actions are defined in `app/lib/actions/` and follow the pattern: +**Server Actions Pattern**: Form handling uses Next.js Server Actions with Zod validation. Actions are defined in `web-app/app/lib/actions/` and follow the pattern: ```typescript export const actionName = withUser(async (user: AuthenticatedUser, ...args) => { // Server action implementation with automatic user context }); ``` -**Internationalization**: Uses next-intl with locale-based routing. Messages are in `messages/` directory. The middleware handles both auth and i18n routing. +**Internationalization**: Uses next-intl with locale-based routing. Messages are in `web-app/messages/` directory. The middleware handles both auth and i18n routing. ### Key Files & Responsibilities -- `middleware.ts` - Handles authentication and i18n routing, defines public pages -- `app/lib/auth.ts` - NextAuth configuration, `withUser` HOF for user context -- `app/lib/dbClient.ts` - MongoDB connection with development/production handling -- `app/lib/actions/` - Server actions for data mutations (locations, bills, months) -- `app/i18n.ts` - Internationalization configuration (Croatian default) -- `next.config.js` - Standalone build config with `serverActions.allowedOrigins` for Docker deployment +- `web-app/middleware.ts` - Handles authentication and i18n routing, defines public pages +- `web-app/app/lib/auth.ts` - NextAuth configuration, `withUser` HOF for user context +- `web-app/app/lib/dbClient.ts` - MongoDB connection with development/production handling +- `web-app/app/lib/actions/` - Server actions for data mutations (locations, bills, months) +- `web-app/app/i18n.ts` - Internationalization configuration (Croatian default) +- `web-app/next.config.js` - Standalone build config with `serverActions.allowedOrigins` for Docker deployment +- `housekeeping/` - Database backup and maintenance scripts ### Database Schema - **Collections**: Locations, Bills, Months (year-month periods) diff --git a/README.md b/README.md index e1e7312..905b94f 100644 --- a/README.md +++ b/README.md @@ -17,46 +17,74 @@ Each location record is marked with a user ID. All the actions user `withUser` to fetch user ID, which is then used in all the DB operations. +# Repository Structure + +This repository contains multiple independent projects: + +- **web-app/**: Next.js application for tracking utility bills +- **docker-stack/**: Docker Compose configurations and deployment scripts +- **housekeeping/**: Database backup and maintenance scripts + +Each project is self-contained with its own dependencies and configuration. + +## Working with Projects + +```bash +# Web app +cd web-app +npm install +npm run dev + +# Deploy with Docker +cd docker-stack +./deploy-standalone.sh 2.20.0 + +# Housekeeping scripts +cd housekeeping +./db-backup--standalone.sh +``` + # Database Backup & Restore The project includes multiple backup strategies for different deployment scenarios and requirements. +All backup scripts are located in the `housekeeping/` workspace. ## Backup Scripts Overview ### Standalone Docker Deployments **Online Backups (No Downtime):** -- `db-dump--standalone.sh` - Creates online backup of the 'utility-bills' database using mongodump +- `housekeeping/db-dump--standalone.sh` - Creates online backup of the 'utility-bills' database using mongodump - Database stays running during backup - Only backs up the database content, not the full volume - Output: `./mongo-backup/utility-bills-dump-YYYY-MM-DD_HH-MM.tar.gz` - Default retention: 7 backups (configurable via `KEEP` env var) - - Usage: `./db-dump--standalone.sh` or `KEEP=10 ./db-dump--standalone.sh` + - Usage: `cd housekeeping && ./db-dump--standalone.sh` or `KEEP=10 ./db-dump--standalone.sh` -- `db-restore-from-dump--standalone.sh` - Restores from mongodump archives +- `housekeeping/db-restore-from-dump--standalone.sh` - Restores from mongodump archives - Database stays running during restore - **WARNING**: Drops existing collections before restore - - Usage: `./db-restore-from-dump--standalone.sh utility-bills-dump-2025-11-26_14-30.tar.gz` + - Usage: `cd housekeeping && ./db-restore-from-dump--standalone.sh utility-bills-dump-2025-11-26_14-30.tar.gz` **Offline Backups (With Downtime):** -- `db-backup--standalone.sh` - Creates offline backup of the complete mongo-volume directory +- `housekeeping/db-backup--standalone.sh` - Creates offline backup of the complete mongo-volume directory - Database container is stopped during backup for consistency - Backs up the entire MongoDB data directory - Output: `./mongo-backup/mongo-volume-backup-YYYY-MM-DD-HH-MM.tar.gz` - Default retention: 7 backups (configurable via `KEEP` env var) - - Usage: `./db-backup--standalone.sh` or `KEEP=2 ./db-backup--standalone.sh` + - Usage: `cd housekeeping && ./db-backup--standalone.sh` or `KEEP=2 ./db-backup--standalone.sh` ### Docker Swarm Deployments -- `db-backup--swarm.sh` - Creates offline backup by scaling down the MongoDB service +- `housekeeping/db-backup--swarm.sh` - Creates offline backup by scaling down the MongoDB service - Service is scaled to 0 during backup - Output: `./mongo-backup/mongo-volume-backup-YYYY-MM-DD-HH-MM.tar.gz` - - Usage: `./db-backup--swarm.sh` + - Usage: `cd housekeeping && ./db-backup--swarm.sh` -- `db-restore-from-backup--swarm.sh` - Restores volume backup by scaling down the service +- `housekeeping/db-restore-from-backup--swarm.sh` - Restores volume backup by scaling down the service - Service is scaled to 0 during restore - Optional `--pre-backup` flag for safety backup before restore - - Usage: `./db-restore-from-backup--swarm.sh mongo-volume-backup-2025-11-26-14-30.tar.gz` + - Usage: `cd housekeeping && ./db-restore-from-backup--swarm.sh mongo-volume-backup-2025-11-26-14-30.tar.gz` ## Automated Backup Schedule @@ -64,10 +92,10 @@ Backups run automatically via cron at 04:00 every day: ```cron # Sunday: Full volume backup (offline), keep 2 backups -0 4 * * 0 cd /home/knee-cola/web-pro/evidencija-rezija && KEEP=2 ./db-backup--standalone.sh +0 4 * * 0 cd /home/knee-cola/web-pro/evidencija-rezija/housekeeping && KEEP=2 ./db-backup--standalone.sh # Monday-Saturday: Database dump (online), keep 6 backups -0 4 * * 1-6 cd /home/knee-cola/web-pro/evidencija-rezija && KEEP=6 ./db-dump--standalone.sh +0 4 * * 1-6 cd /home/knee-cola/web-pro/evidencija-rezija/housekeeping && KEEP=6 ./db-dump--standalone.sh ``` **Backup Strategy:** @@ -89,21 +117,32 @@ All backups are stored in `./mongo-backup/`: This directory is excluded from git via `.gitignore`. # Deploying -The deployment is done via Docker: -* build docker image -* deploy Docker service -## Building Docker image -Run the following command: +The deployment is done via Docker. + +## Building Docker Image + +From the `web-app/` directory: + ```bash -build.sh +cd web-app +./build.sh 2.20.0 ``` + The image will be stored in the local Docker instance. -## Deploying Docker service -Run the following command: +## Deploying Docker Service + +From the `docker-stack/` directory: + ```bash -deploy.sh +cd docker-stack + +# Standalone deployment +./deploy-standalone.sh 2.20.0 + +# Or Swarm deployment +./deploy-swarm.sh 2.20.0 ``` # Implementation details diff --git a/docker-stack/README.md b/docker-stack/README.md new file mode 100644 index 0000000..483d45b --- /dev/null +++ b/docker-stack/README.md @@ -0,0 +1,46 @@ +# Docker Stack + +Docker Compose configurations and deployment scripts for the Evidencija Režija application. + +## Files + +### Docker Compose Configurations + +- `docker-compose-standalone.yaml` - Standalone deployment with docker-compose +- `docker-compose-swarm.yml` - Docker Swarm deployment +- `docker-compose-debug.yml` - Debug/development deployment + +### Deployment Scripts + +- `deploy-standalone.sh` - Deploy standalone configuration +- `deploy-swarm.sh` - Deploy swarm configuration + +## Usage + +### Deploying Standalone + +```bash +cd docker-stack +./deploy-standalone.sh 2.20.0 +``` + +### Deploying to Swarm + +```bash +cd docker-stack +./deploy-swarm.sh 2.20.0 +``` + +## Prerequisites + +- Docker image must be built first: `cd ../web-app && ./build.sh 2.20.0` +- MongoDB data directory: `../mongo-volume/` +- MongoDB backup directory: `../mongo-backup/` + +## Configuration + +All compose files reference: +- Web app image: `utility-bills-tracker:${IMAGE_VERSION}` +- Volume mounts: `../web-app/etc/hosts/` +- MongoDB data: `../mongo-volume/` +- MongoDB backups: `../mongo-backup/` diff --git a/deploy-standalone.sh b/docker-stack/deploy-standalone.sh similarity index 100% rename from deploy-standalone.sh rename to docker-stack/deploy-standalone.sh diff --git a/deploy-swarm.sh b/docker-stack/deploy-swarm.sh similarity index 100% rename from deploy-swarm.sh rename to docker-stack/deploy-swarm.sh diff --git a/docker-compose-standalone.yaml b/docker-stack/docker-compose-standalone.yaml similarity index 98% rename from docker-compose-standalone.yaml rename to docker-stack/docker-compose-standalone.yaml index 55bfd31..38d7610 100644 --- a/docker-compose-standalone.yaml +++ b/docker-stack/docker-compose-standalone.yaml @@ -18,7 +18,7 @@ services: - traefik-network - util-bills-mongo-network volumes: - - ./etc/hosts/:/etc/hosts + - ./web-app/etc/hosts/:/etc/hosts environment: MONGODB_URI: mongodb://rezije.app:w4z4piJBgCdAm4tpawqB@mongo:27017/utility-bills GOOGLE_ID: 355397364527-adjrokm6hromcaaar0qfhk050mfr35ou.apps.googleusercontent.com diff --git a/docker-compose-swarm.yml b/docker-stack/docker-compose-swarm.yml similarity index 98% rename from docker-compose-swarm.yml rename to docker-stack/docker-compose-swarm.yml index bd6d34f..a212af8 100644 --- a/docker-compose-swarm.yml +++ b/docker-stack/docker-compose-swarm.yml @@ -18,7 +18,7 @@ services: - traefik-network - util-bills-mongo-network volumes: - - ./etc/hosts/:/etc/hosts + - ./web-app/etc/hosts/:/etc/hosts environment: MONGODB_URI: mongodb://rezije.app:w4z4piJBgCdAm4tpawqB@mongo:27017/utility-bills GOOGLE_ID: 355397364527-adjrokm6hromcaaar0qfhk050mfr35ou.apps.googleusercontent.com diff --git a/docker-stack/package.json b/docker-stack/package.json new file mode 100644 index 0000000..866b77e --- /dev/null +++ b/docker-stack/package.json @@ -0,0 +1,10 @@ +{ + "name": "docker-stack", + "version": "2.20.0", + "private": true, + "description": "Docker deployment configurations and scripts", + "scripts": { + "deploy:standalone": "./deploy-standalone.sh", + "deploy:swarm": "./deploy-swarm.sh" + } +} diff --git a/email-server-worker/README.md b/email-server-worker/README.md new file mode 100644 index 0000000..901dea8 --- /dev/null +++ b/email-server-worker/README.md @@ -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 diff --git a/email-server-worker/sent-mail-tester.mjs b/email-server-worker/sent-mail-tester.mjs new file mode 100644 index 0000000..873406f --- /dev/null +++ b/email-server-worker/sent-mail-tester.mjs @@ -0,0 +1,27 @@ +import FormData from "form-data"; // form-data v4.0.1 +import Mailgun from "mailgun.js"; // mailgun.js v11.1.0 + +async function sendSimpleMessage() { + const mailgun = new Mailgun(FormData); + const mg = mailgun.client({ + username: "api", + key: process.env.API_KEY || "f581edcac21ec14d086ef25e36f04432-e61ae8dd-e207f22b", + // When you have an EU-domain, you must specify the endpoint: + url: "https://api.eu.mailgun.net" + }); + try { + console.log("Sending email..."); + const data = await mg.messages.create("rezije.app", { + from: "Mailgun Sandbox ", + to: ["Nikola Derezic "], + subject: "Hello Nikola Derezic", + text: "Congratulations Nikola Derezic, you just sent an email with Mailgun! You are truly awesome!", + }); + + console.log(data); // logs response data + } catch (error) { + console.log(error); //logs any error + } +} + +sendSimpleMessage(); \ No newline at end of file diff --git a/evidencija-rezija.code-workspace b/evidencija-rezija.code-workspace new file mode 100644 index 0000000..4a8c1c2 --- /dev/null +++ b/evidencija-rezija.code-workspace @@ -0,0 +1,51 @@ +{ + "folders": [ + { + "name": "🌐 web-app", + "path": "web-app" + }, + { + "name": "🐳 docker-stack", + "path": "docker-stack" + }, + { + "name": "🔧 housekeeping", + "path": "housekeeping" + }, + { + "name": "📧 mailgun-webhook", + "path": "mailgun-webhook" + }, + { + "name": "⚙️ email-server-worker", + "path": "email-server-worker" + }, + { + "name": "📦 root", + "path": "." + } + ], + "settings": { + "files.exclude": { + "**/node_modules": true, + "**/.next": true, + "**/.git": false + }, + "search.exclude": { + "**/node_modules": true, + "**/.next": true, + "**/package-lock.json": true + }, + "typescript.tsdk": "web-app/node_modules/typescript/lib", + "eslint.workingDirectories": [ + "web-app" + ] + }, + "extensions": { + "recommendations": [ + "dbaeumer.vscode-eslint", + "esbenp.prettier-vscode", + "bradlc.vscode-tailwindcss" + ] + } +} diff --git a/housekeeping/README.md b/housekeeping/README.md new file mode 100644 index 0000000..f762553 --- /dev/null +++ b/housekeeping/README.md @@ -0,0 +1,20 @@ +# Housekeeping + +Database backup and maintenance scripts for the Evidencija Režija application. + +## Scripts + +- `db-backup--standalone.sh` - Backup database in standalone deployment +- `db-backup--swarm.sh` - Backup database in Docker Swarm deployment +- `db-dump--standalone.sh` - Dump database in standalone deployment +- `db-restore-from-dump--standalone.sh` - Restore from dump in standalone deployment +- `db-restore-from-backup--swarm.sh` - Restore from backup in Docker Swarm deployment + +## Usage + +From the housekeeping directory: + +```bash +cd housekeeping +./db-backup--standalone.sh +``` diff --git a/db-backup--standalone.sh b/housekeeping/db-backup--standalone.sh similarity index 100% rename from db-backup--standalone.sh rename to housekeeping/db-backup--standalone.sh diff --git a/db-backup--swarm.sh b/housekeeping/db-backup--swarm.sh similarity index 100% rename from db-backup--swarm.sh rename to housekeeping/db-backup--swarm.sh diff --git a/db-dump--standalone.sh b/housekeeping/db-dump--standalone.sh similarity index 100% rename from db-dump--standalone.sh rename to housekeeping/db-dump--standalone.sh diff --git a/db-restore-from-backup--swarm.sh b/housekeeping/db-restore-from-backup--swarm.sh similarity index 100% rename from db-restore-from-backup--swarm.sh rename to housekeeping/db-restore-from-backup--swarm.sh diff --git a/db-restore-from-dump--standalone.sh b/housekeeping/db-restore-from-dump--standalone.sh similarity index 100% rename from db-restore-from-dump--standalone.sh rename to housekeeping/db-restore-from-dump--standalone.sh diff --git a/housekeeping/package.json b/housekeeping/package.json new file mode 100644 index 0000000..fcfee20 --- /dev/null +++ b/housekeeping/package.json @@ -0,0 +1,13 @@ +{ + "name": "housekeeping", + "version": "2.20.0", + "private": true, + "description": "Database backup and maintenance scripts", + "scripts": { + "backup:standalone": "./db-backup--standalone.sh", + "backup:swarm": "./db-backup--swarm.sh", + "dump:standalone": "./db-dump--standalone.sh", + "restore:standalone": "./db-restore-from-dump--standalone.sh", + "restore:swarm": "./db-restore-from-backup--swarm.sh" + } +} diff --git a/mailgun-webhook/README.md b/mailgun-webhook/README.md new file mode 100644 index 0000000..0995202 --- /dev/null +++ b/mailgun-webhook/README.md @@ -0,0 +1,23 @@ +# Mailgun Webhook Handler + +This workspace contains the Mailgun webhook handler service for processing email events related to the Evidencija Režija tenant notification system. + +## Purpose + +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.) + +## Architecture + +This is a separate system from the Next.js web-app that communicates via the shared MongoDB database. + +## Setup + +TBD + +## Environment Variables + +TBD diff --git a/sprints/sprint--confirm-unsubscribe.md b/sprints/sprint--confirm-unsubscribe.md new file mode 100644 index 0000000..10a8b14 --- /dev/null +++ b/sprints/sprint--confirm-unsubscribe.md @@ -0,0 +1,83 @@ +# Context +App users (landlord) can assign `tenantEmail` to a `BillingLocation`. + +This is a e-mail address will be used to notify the tenant when the rent is due and/or the utility bills are due. + +## E-mail verification +To prevent missuse and ensure that the e-mail is correct, before an e-mail address can be used by the automatic notification system, the tenant needs to verifies that he/she accepts to receive notifications. + +This verification is done via a link sent to the tenant in a verification-request e-mail, which is sent to the tenant automatically when the landloard (app user) assigns this e-mail address to a BillingLocation. + +Sending of this verification-request e-mail is handled by a system separate from NextJS app in `web-app` workspace. It detects newly assigned addresses from their status bein equal `EmailStatus.Unverified`. The two systems don't talk to each other at all - what's holding them together is the DB. + +### Implementation details +Verification link points to the NextJS app in `web-app` workspace at path `/email/verify/[share-id]` (share-id is calculated using `generateShareId` from `/home/kneecola/projects/evidencija-rezija/web-app/app/lib/shareChecksum.ts`). + +The web page served at this path cerifies if the [share-id] is correct and if not it shows 404 page. + +The web page served at this path contains an text explanation and "Verify e-mail" button. + +The text includes the following information: +* what the web app is about - very short into +* why the e-mail was sent = because the landloard of the property `BillingLocation.name` configured the rent (`BillingLocation.rentDueNotification`) and/or utility bills (`BillingLocation.billFwdStrategy`) to be delivered to that e-mail address +* what will hapen if he/she clicks on the "Verify e-mail" button = they will be receiving rent due (`BillingLocation.rentDueNotification`) or utility bills due (`BillingLocation.billFwdStrategy`) notification or both - 2x a month - depending on the config set by the landloard +* opt-out infomation (they can ignore this e-mail, but can also opt-out at any moment) + +If the user clicks the button "Verify e-mail" this triggers update of `BillingLocation.tenantEmailStatus`. + +Here's the expected stats flow: + +* landloard/app user assigns an an new address to `BillingLocation` -> `BillingLocation.tenantEmailStatus` is set to `EmailStatus.Unverified` +* an automated system detects that a new address was set (as indicated by `EmailStatus.Unverified` status), it then sets verification-email -> `BillingLocation.tenantEmailStatus` is set to `EmailStatus.VerificationPending` +* tenant click the link from the verification-requets e-mail -> `BillingLocation.tenantEmailStatus` is set to `EmailStatus.Verified` + +**Note:** status is updated for the `BillingLocation` inidcated by locationID (decoded from `[share-id]` param), and all subsequent (later in time) matching `BillingLocation` records (matched by comparing `BillingLocation.name`, `BillingLocation.userId` and `BillingLocation.tenantEmail`). Similar pattern is already implemented in `updateOrAddLocation` fn in `locationAction.ts` (`updateScope === "subsequent"`). + +## E-mail unsubscribe +Tenant can out-out from receiving e-mail notifications at any time via an `unsubscribe` link included at the end of every mail sent to the tenant. + +### Implementation details +Verification link points to the NextJS app in `web-app` workspace at path `/email/unsubscribe/[share-id]` (share-id is calculated using `generateShareId` from `/home/kneecola/projects/evidencija-rezija/web-app/app/lib/shareChecksum.ts` ... search of examples of how this function is used). + +The web page served at this path contains an text explanation and "Confirm unsubscribe" button. + +The text includes the following information: +* what the web app is about - very short into +* why are they receiveing e-mails from this page = because their landlord for property `BillingLocation.name` has configured the app to deliver rent due or utility bills due notification or both to that address +* what will hapen if they click on "Confirm unsubscribe" = they will no longer receive rent due / utility bull due reminders + +E-mail address's verification status is tracked via `BillingLocation.tenantEmailStatus`, which is set to `EmailStatus.Unsubscribed`. + +**Note:** status is updated for the `BillingLocation` inidcated by locationID (decoded from `[share-id]` param), and all subsequent (later in time) matching `BillingLocation` records (matched by comparing `BillingLocation.name`, `BillingLocation.userId` and `BillingLocation.tenantEmail`). Similar pattern is already implemented in `updateOrAddLocation` fn in `locationAction.ts` (`updateScope === "subsequent"`). + +## E-mail status in `LocationCard.tsx` + +If the e-mail is not in `EmailStatus.Verified` state for a given location, then this will be indicated in `LocationCard.tsx` as a sibling of `total-payed-label` block (`
`) + +## E-mail status in `LocationEditForm.tsx` + +Current e-mail status will be indicated as a sibling of: +``` + {/* Email status indicator should go here */} +
+ ... +``` + +Use appropriate utf-8 icon for each status. + +# Logical Units of work + +Work will be split in logical units of work: + +* implement e-mail verification DB logic +* implement e-mail verification page + * create text both in croatian (hr.json) and english (en.json) + * implement share-id verification (see /home/kneecola/projects/evidencija-rezija/web-app/app/[locale]/share/proof-of-payment/per-bill/[id]/route.tsx) +* implement e-mail unsubscribe DB logic +* implement e-mail unsubscribe page + * create text both in croatian (hr.json) and english (en.json) + * implement share-id verification (see /home/kneecola/projects/evidencija-rezija/web-app/app/[locale]/share/proof-of-payment/per-bill/[id]/route.tsx) +* add email status to `LocationCard.tsx` +* add email status to `LocationEditForm.tsx` + +Each logical unit of work will be commited separatley. \ No newline at end of file diff --git a/.dockerignore b/web-app/.dockerignore similarity index 100% rename from .dockerignore rename to web-app/.dockerignore diff --git a/.env b/web-app/.env similarity index 100% rename from .env rename to web-app/.env diff --git a/.eslintrc.json b/web-app/.eslintrc.json similarity index 100% rename from .eslintrc.json rename to web-app/.eslintrc.json diff --git a/.nvmrc b/web-app/.nvmrc similarity index 100% rename from .nvmrc rename to web-app/.nvmrc diff --git a/.vscode/launch.json b/web-app/.vscode/launch.json similarity index 88% rename from .vscode/launch.json rename to web-app/.vscode/launch.json index 2772a38..d3ddab6 100644 --- a/.vscode/launch.json +++ b/web-app/.vscode/launch.json @@ -8,7 +8,7 @@ "name": "Debug", "type": "node", "request": "launch", - "envFile": "${workspaceFolder}/.env", + "envFile": "${workspaceFolder:🌐 web-app}/.env", "env": { "USE_MOCK_AUTH": "true", "MOCK_USER_ID": "109754742613069927799", @@ -19,8 +19,8 @@ "dev" // this is `dev` from `npm run dev` ], "runtimeExecutable": "npm", - "cwd": "${workspaceFolder}/", - "localRoot": "${workspaceFolder}/", + "cwd": "${workspaceFolder:🌐 web-app}/", + "localRoot": "${workspaceFolder:🌐 web-app}/", "remoteRoot": "/app/", "skipFiles": [ "/**", diff --git a/.vscode/tasks.json b/web-app/.vscode/tasks.json similarity index 84% rename from .vscode/tasks.json rename to web-app/.vscode/tasks.json index b24ef6c..8d09f9e 100644 --- a/.vscode/tasks.json +++ b/web-app/.vscode/tasks.json @@ -13,7 +13,7 @@ "build": true, }, "files": [ - "${workspaceFolder}/docker-compose-debug.yml" + "${workspaceFolder:🌐 web-app}/docker-compose-debug.yml" ], "isBackground": true, "problemMatcher": "Terminal will be reused by tasks, press any key to close it" @@ -25,7 +25,7 @@ "dockerCompose": { "down": {}, "files": [ - "${workspaceFolder}/docker-compose-debug.yml" + "${workspaceFolder:🌐 web-app}/docker-compose-debug.yml" ], } }, diff --git a/Dockerfile b/web-app/Dockerfile similarity index 93% rename from Dockerfile rename to web-app/Dockerfile index acc464f..a83f43e 100644 --- a/Dockerfile +++ b/web-app/Dockerfile @@ -12,16 +12,16 @@ RUN apk add --no-cache libc6-compat WORKDIR /app -# package.json and package-lock.json +# Copy package files COPY ./package.json ./package-lock.json ./ -# installing dependencies +# Install dependencies RUN npm i && npm cache clean --force -# copy all the soruce code +# Copy application source code COPY . . -# building app +# Build application RUN npm run build #----------------------------------------- diff --git a/app/[locale]/attachment/[id]/not-found.tsx b/web-app/app/[locale]/attachment/[id]/not-found.tsx similarity index 100% rename from app/[locale]/attachment/[id]/not-found.tsx rename to web-app/app/[locale]/attachment/[id]/not-found.tsx diff --git a/app/[locale]/attachment/[id]/route.tsx b/web-app/app/[locale]/attachment/[id]/route.tsx similarity index 100% rename from app/[locale]/attachment/[id]/route.tsx rename to web-app/app/[locale]/attachment/[id]/route.tsx diff --git a/web-app/app/[locale]/email/unsubscribe/[id]/EmailUnsubscribePage.tsx b/web-app/app/[locale]/email/unsubscribe/[id]/EmailUnsubscribePage.tsx new file mode 100644 index 0000000..a57dcf1 --- /dev/null +++ b/web-app/app/[locale]/email/unsubscribe/[id]/EmailUnsubscribePage.tsx @@ -0,0 +1,117 @@ +'use client'; + +import { useTranslations } from 'next-intl'; +import { useState } from 'react'; +import { unsubscribeTenantEmail } from '@/app/lib/actions/emailActions'; +import { CheckCircleIcon } from '@heroicons/react/24/outline'; + +interface EmailUnsubscribePageProps { + shareId: string; + isVerified: boolean; +} + +export default function EmailUnsubscribePage({ shareId, isVerified }: EmailUnsubscribePageProps) { + const t = useTranslations('email-unsubscribe-page'); + const [isUnsubscribing, setIsUnsubscribing] = useState(false); + const [isUnsubscribed, setIsUnsubscribed] = useState(false); + const [error, setError] = useState(null); + + const handleUnsubscribe = async () => { + setIsUnsubscribing(true); + setError(null); + + try { + const result = await unsubscribeTenantEmail(shareId); + + if (result.success) { + setIsUnsubscribed(true); + } else { + setError(result.message || t('error.unknown')); + } + } catch (err) { + setError(t('error.unknown')); + } finally { + setIsUnsubscribing(false); + } + }; + + if (isUnsubscribed) { + return ( +
+
+
+ +
+

+ {t('success.title')} +

+

{t('success.message')}

+
+
+ ); + } + + if (error) { + return ( +
+
+

{t('error.title')}

+

{error}

+
+
+ ); + } + + if (!isVerified) { + return ( +
+
+

{t('not-allowed.title')}

+

{t('not-allowed.message')}

+
+
+ ); + } + + return ( +
+
+

{t('title')}

+ +
+
+

{t('about.title')}

+

{t('about.description')}

+
+ +
+

{t('why.title')}

+

{t('why.description')}

+
+ +
+

{t('what-happens.title')}

+

{t('what-happens.description')}

+
+
+ +
+ +
+
+
+ ); +} diff --git a/web-app/app/[locale]/email/unsubscribe/[id]/page.tsx b/web-app/app/[locale]/email/unsubscribe/[id]/page.tsx new file mode 100644 index 0000000..5c7c5a2 --- /dev/null +++ b/web-app/app/[locale]/email/unsubscribe/[id]/page.tsx @@ -0,0 +1,45 @@ +import { Suspense } from 'react'; +import EmailUnsubscribePage from './EmailUnsubscribePage'; +import { Main } from '@/app/ui/Main'; +import { getDbClient } from '@/app/lib/dbClient'; +import { BillingLocation, EmailStatus } from '@/app/lib/db-types'; +import { extractShareId, validateShareChecksum } from '@/app/lib/shareChecksum'; +import { notFound } from 'next/navigation'; + +export default async function Page({ params: { id } }: { params: { id: string } }) { + // Extract and validate share ID + const extracted = extractShareId(id); + if (!extracted) { + notFound(); + } + + const { locationId, checksum } = extracted; + + // Validate checksum + if (!validateShareChecksum(locationId, checksum)) { + notFound(); + } + + // Fetch location to check email status + const dbClient = await getDbClient(); + const location = await dbClient.collection("lokacije") + .findOne( + { _id: locationId }, + { projection: { tenantEmail: 1, tenantEmailStatus: 1 } } + ); + + if (!location || !location.tenantEmail) { + notFound(); + } + + // Check if email is verified + const isVerified = location.tenantEmailStatus === EmailStatus.Verified; + + return ( +
+ Loading...
}> + + + + ); +} diff --git a/web-app/app/[locale]/email/verify/[id]/EmailVerifyPage.tsx b/web-app/app/[locale]/email/verify/[id]/EmailVerifyPage.tsx new file mode 100644 index 0000000..a48de4a --- /dev/null +++ b/web-app/app/[locale]/email/verify/[id]/EmailVerifyPage.tsx @@ -0,0 +1,122 @@ +'use client'; + +import { useTranslations } from 'next-intl'; +import { useState } from 'react'; +import { verifyTenantEmail } from '@/app/lib/actions/emailActions'; +import { CheckCircleIcon } from '@heroicons/react/24/outline'; + +interface EmailVerifyPageProps { + shareId: string; + isPending: boolean; +} + +export default function EmailVerifyPage({ shareId, isPending }: EmailVerifyPageProps) { + const t = useTranslations('email-verify-page'); + const [isVerifying, setIsVerifying] = useState(false); + const [isVerified, setIsVerified] = useState(false); + const [error, setError] = useState(null); + + const handleVerify = async () => { + setIsVerifying(true); + setError(null); + + try { + const result = await verifyTenantEmail(shareId); + + if (result.success) { + setIsVerified(true); + } else { + setError(result.message || t('error.unknown')); + } + } catch (err) { + setError(t('error.unknown')); + } finally { + setIsVerifying(false); + } + }; + + if (isVerified) { + return ( +
+
+
+ +
+

+ {t('success.title')} +

+

{t('success.message')}

+
+
+ ); + } + + if (error) { + return ( +
+
+

{t('error.title')}

+

{error}

+
+
+ ); + } + + if (!isPending) { + return ( +
+
+

{t('not-allowed.title')}

+

{t('not-allowed.message')}

+
+
+ ); + } + + return ( +
+
+

{t('title')}

+ +
+
+

{t('about.title')}

+

{t('about.description')}

+
+ +
+

{t('why.title')}

+

{t('why.description')}

+
+ +
+

{t('what-happens.title')}

+

{t('what-happens.description')}

+
+ +
+

{t('opt-out.title')}

+

{t('opt-out.description')}

+
+
+ +
+ +
+
+
+ ); +} diff --git a/web-app/app/[locale]/email/verify/[id]/page.tsx b/web-app/app/[locale]/email/verify/[id]/page.tsx new file mode 100644 index 0000000..3b4cc9d --- /dev/null +++ b/web-app/app/[locale]/email/verify/[id]/page.tsx @@ -0,0 +1,45 @@ +import { Suspense } from 'react'; +import EmailVerifyPage from './EmailVerifyPage'; +import { Main } from '@/app/ui/Main'; +import { getDbClient } from '@/app/lib/dbClient'; +import { BillingLocation, EmailStatus } from '@/app/lib/db-types'; +import { extractShareId, validateShareChecksum } from '@/app/lib/shareChecksum'; +import { notFound } from 'next/navigation'; + +export default async function Page({ params: { id } }: { params: { id: string } }) { + // Extract and validate share ID + const extracted = extractShareId(id); + if (!extracted) { + notFound(); + } + + const { locationId, checksum } = extracted; + + // Validate checksum + if (!validateShareChecksum(locationId, checksum)) { + notFound(); + } + + // Fetch location to check email status + const dbClient = await getDbClient(); + const location = await dbClient.collection("lokacije") + .findOne( + { _id: locationId }, + { projection: { tenantEmail: 1, tenantEmailStatus: 1 } } + ); + + if (!location || !location.tenantEmail) { + notFound(); + } + + // Check if email is pending verification + const isPending = location.tenantEmailStatus === EmailStatus.VerificationPending; + + return ( +
+ Loading...
}> + + + + ); +} diff --git a/app/[locale]/home/account/LogoutButton.tsx b/web-app/app/[locale]/home/account/LogoutButton.tsx similarity index 100% rename from app/[locale]/home/account/LogoutButton.tsx rename to web-app/app/[locale]/home/account/LogoutButton.tsx diff --git a/app/[locale]/home/account/page.tsx b/web-app/app/[locale]/home/account/page.tsx similarity index 100% rename from app/[locale]/home/account/page.tsx rename to web-app/app/[locale]/home/account/page.tsx diff --git a/app/[locale]/home/account/settings/page.tsx b/web-app/app/[locale]/home/account/settings/page.tsx similarity index 100% rename from app/[locale]/home/account/settings/page.tsx rename to web-app/app/[locale]/home/account/settings/page.tsx diff --git a/app/[locale]/home/bill/[id]/add/not-found.tsx b/web-app/app/[locale]/home/bill/[id]/add/not-found.tsx similarity index 100% rename from app/[locale]/home/bill/[id]/add/not-found.tsx rename to web-app/app/[locale]/home/bill/[id]/add/not-found.tsx diff --git a/app/[locale]/home/bill/[id]/add/page.tsx b/web-app/app/[locale]/home/bill/[id]/add/page.tsx similarity index 100% rename from app/[locale]/home/bill/[id]/add/page.tsx rename to web-app/app/[locale]/home/bill/[id]/add/page.tsx diff --git a/app/[locale]/home/bill/[id]/delete/not-found.tsx b/web-app/app/[locale]/home/bill/[id]/delete/not-found.tsx similarity index 100% rename from app/[locale]/home/bill/[id]/delete/not-found.tsx rename to web-app/app/[locale]/home/bill/[id]/delete/not-found.tsx diff --git a/app/[locale]/home/bill/[id]/delete/page.tsx b/web-app/app/[locale]/home/bill/[id]/delete/page.tsx similarity index 100% rename from app/[locale]/home/bill/[id]/delete/page.tsx rename to web-app/app/[locale]/home/bill/[id]/delete/page.tsx diff --git a/app/[locale]/home/bill/[id]/edit/not-found.tsx b/web-app/app/[locale]/home/bill/[id]/edit/not-found.tsx similarity index 100% rename from app/[locale]/home/bill/[id]/edit/not-found.tsx rename to web-app/app/[locale]/home/bill/[id]/edit/not-found.tsx diff --git a/app/[locale]/home/bill/[id]/edit/page.tsx b/web-app/app/[locale]/home/bill/[id]/edit/page.tsx similarity index 100% rename from app/[locale]/home/bill/[id]/edit/page.tsx rename to web-app/app/[locale]/home/bill/[id]/edit/page.tsx diff --git a/app/[locale]/home/location/[id]/add/LocationAddPage.tsx b/web-app/app/[locale]/home/location/[id]/add/LocationAddPage.tsx similarity index 100% rename from app/[locale]/home/location/[id]/add/LocationAddPage.tsx rename to web-app/app/[locale]/home/location/[id]/add/LocationAddPage.tsx diff --git a/app/[locale]/home/location/[id]/add/page.tsx b/web-app/app/[locale]/home/location/[id]/add/page.tsx similarity index 100% rename from app/[locale]/home/location/[id]/add/page.tsx rename to web-app/app/[locale]/home/location/[id]/add/page.tsx diff --git a/app/[locale]/home/location/[id]/delete/LocationDeletePage.tsx b/web-app/app/[locale]/home/location/[id]/delete/LocationDeletePage.tsx similarity index 100% rename from app/[locale]/home/location/[id]/delete/LocationDeletePage.tsx rename to web-app/app/[locale]/home/location/[id]/delete/LocationDeletePage.tsx diff --git a/app/[locale]/home/location/[id]/delete/not-found.tsx b/web-app/app/[locale]/home/location/[id]/delete/not-found.tsx similarity index 100% rename from app/[locale]/home/location/[id]/delete/not-found.tsx rename to web-app/app/[locale]/home/location/[id]/delete/not-found.tsx diff --git a/app/[locale]/home/location/[id]/delete/page.tsx b/web-app/app/[locale]/home/location/[id]/delete/page.tsx similarity index 100% rename from app/[locale]/home/location/[id]/delete/page.tsx rename to web-app/app/[locale]/home/location/[id]/delete/page.tsx diff --git a/app/[locale]/home/location/[id]/edit/LocationEditPage.tsx b/web-app/app/[locale]/home/location/[id]/edit/LocationEditPage.tsx similarity index 100% rename from app/[locale]/home/location/[id]/edit/LocationEditPage.tsx rename to web-app/app/[locale]/home/location/[id]/edit/LocationEditPage.tsx diff --git a/app/[locale]/home/location/[id]/edit/not-found.tsx b/web-app/app/[locale]/home/location/[id]/edit/not-found.tsx similarity index 100% rename from app/[locale]/home/location/[id]/edit/not-found.tsx rename to web-app/app/[locale]/home/location/[id]/edit/not-found.tsx diff --git a/app/[locale]/home/location/[id]/edit/page.tsx b/web-app/app/[locale]/home/location/[id]/edit/page.tsx similarity index 100% rename from app/[locale]/home/location/[id]/edit/page.tsx rename to web-app/app/[locale]/home/location/[id]/edit/page.tsx diff --git a/app/[locale]/home/multi-bill-edit/[year]/[month]/BillToggleBadge.tsx b/web-app/app/[locale]/home/multi-bill-edit/[year]/[month]/BillToggleBadge.tsx similarity index 100% rename from app/[locale]/home/multi-bill-edit/[year]/[month]/BillToggleBadge.tsx rename to web-app/app/[locale]/home/multi-bill-edit/[year]/[month]/BillToggleBadge.tsx diff --git a/app/[locale]/home/multi-bill-edit/[year]/[month]/MultiBillEdit.tsx b/web-app/app/[locale]/home/multi-bill-edit/[year]/[month]/MultiBillEdit.tsx similarity index 100% rename from app/[locale]/home/multi-bill-edit/[year]/[month]/MultiBillEdit.tsx rename to web-app/app/[locale]/home/multi-bill-edit/[year]/[month]/MultiBillEdit.tsx diff --git a/app/[locale]/home/multi-bill-edit/[year]/[month]/MultiBillEditButton.tsx b/web-app/app/[locale]/home/multi-bill-edit/[year]/[month]/MultiBillEditButton.tsx similarity index 100% rename from app/[locale]/home/multi-bill-edit/[year]/[month]/MultiBillEditButton.tsx rename to web-app/app/[locale]/home/multi-bill-edit/[year]/[month]/MultiBillEditButton.tsx diff --git a/app/[locale]/home/multi-bill-edit/[year]/[month]/MultiBillEditPage.tsx b/web-app/app/[locale]/home/multi-bill-edit/[year]/[month]/MultiBillEditPage.tsx similarity index 100% rename from app/[locale]/home/multi-bill-edit/[year]/[month]/MultiBillEditPage.tsx rename to web-app/app/[locale]/home/multi-bill-edit/[year]/[month]/MultiBillEditPage.tsx diff --git a/app/[locale]/home/multi-bill-edit/[year]/[month]/not-found.tsx b/web-app/app/[locale]/home/multi-bill-edit/[year]/[month]/not-found.tsx similarity index 100% rename from app/[locale]/home/multi-bill-edit/[year]/[month]/not-found.tsx rename to web-app/app/[locale]/home/multi-bill-edit/[year]/[month]/not-found.tsx diff --git a/app/[locale]/home/multi-bill-edit/[year]/[month]/page.tsx b/web-app/app/[locale]/home/multi-bill-edit/[year]/[month]/page.tsx similarity index 100% rename from app/[locale]/home/multi-bill-edit/[year]/[month]/page.tsx rename to web-app/app/[locale]/home/multi-bill-edit/[year]/[month]/page.tsx diff --git a/app/[locale]/home/page.tsx b/web-app/app/[locale]/home/page.tsx similarity index 100% rename from app/[locale]/home/page.tsx rename to web-app/app/[locale]/home/page.tsx diff --git a/app/[locale]/home/print/[year]/[month]/not-found.tsx b/web-app/app/[locale]/home/print/[year]/[month]/not-found.tsx similarity index 100% rename from app/[locale]/home/print/[year]/[month]/not-found.tsx rename to web-app/app/[locale]/home/print/[year]/[month]/not-found.tsx diff --git a/app/[locale]/home/print/[year]/[month]/page.tsx b/web-app/app/[locale]/home/print/[year]/[month]/page.tsx similarity index 100% rename from app/[locale]/home/print/[year]/[month]/page.tsx rename to web-app/app/[locale]/home/print/[year]/[month]/page.tsx diff --git a/app/[locale]/home/year-month/[id]/add/page.tsx b/web-app/app/[locale]/home/year-month/[id]/add/page.tsx similarity index 100% rename from app/[locale]/home/year-month/[id]/add/page.tsx rename to web-app/app/[locale]/home/year-month/[id]/add/page.tsx diff --git a/app/[locale]/layout.tsx b/web-app/app/[locale]/layout.tsx similarity index 100% rename from app/[locale]/layout.tsx rename to web-app/app/[locale]/layout.tsx diff --git a/app/[locale]/page.tsx b/web-app/app/[locale]/page.tsx similarity index 98% rename from app/[locale]/page.tsx rename to web-app/app/[locale]/page.tsx index dbe99e8..229db13 100644 --- a/app/[locale]/page.tsx +++ b/web-app/app/[locale]/page.tsx @@ -9,6 +9,7 @@ import { ExclamationTriangleIcon } from "@heroicons/react/24/outline"; import { paragraphFormatFactory } from '../lib/paragraphFormatFactory'; import { getAuthProviders } from '../lib/getProviders'; import { EnterOrSignInButton } from '../ui/EnterOrSignInButton'; +import Link from 'next/link'; const h1ClassName = "text-3xl font-bold max-w-[38rem] mx-auto text-neutral-50"; const h2ClassName = h1ClassName + " mt-8"; @@ -33,11 +34,10 @@ const Page: FC = async () => {

{t.rich("main-card.title", paragraphFormat)}

- {t.rich("main-card.text", paragraphFormat)} - {t("main-card.image-alt")} - + {t.rich("main-card.text", paragraphFormat)} + {t("main-card.image-alt")} { // Google will refuse OAuth signin if it's inside a webview (i.e. Facebook) insideWebeview && diff --git a/app/[locale]/privacy-policy/page.tsx b/web-app/app/[locale]/privacy-policy/page.tsx similarity index 100% rename from app/[locale]/privacy-policy/page.tsx rename to web-app/app/[locale]/privacy-policy/page.tsx diff --git a/app/[locale]/share/attachment/[id]/not-found.tsx b/web-app/app/[locale]/share/attachment/[id]/not-found.tsx similarity index 100% rename from app/[locale]/share/attachment/[id]/not-found.tsx rename to web-app/app/[locale]/share/attachment/[id]/not-found.tsx diff --git a/app/[locale]/share/attachment/[id]/route.tsx b/web-app/app/[locale]/share/attachment/[id]/route.tsx similarity index 100% rename from app/[locale]/share/attachment/[id]/route.tsx rename to web-app/app/[locale]/share/attachment/[id]/route.tsx diff --git a/app/[locale]/share/bill/[id]/not-found.tsx b/web-app/app/[locale]/share/bill/[id]/not-found.tsx similarity index 100% rename from app/[locale]/share/bill/[id]/not-found.tsx rename to web-app/app/[locale]/share/bill/[id]/not-found.tsx diff --git a/app/[locale]/share/bill/[id]/page.tsx b/web-app/app/[locale]/share/bill/[id]/page.tsx similarity index 100% rename from app/[locale]/share/bill/[id]/page.tsx rename to web-app/app/[locale]/share/bill/[id]/page.tsx diff --git a/app/[locale]/share/location/[id]/LocationViewPage.tsx b/web-app/app/[locale]/share/location/[id]/LocationViewPage.tsx similarity index 100% rename from app/[locale]/share/location/[id]/LocationViewPage.tsx rename to web-app/app/[locale]/share/location/[id]/LocationViewPage.tsx diff --git a/app/[locale]/share/location/[id]/page.tsx b/web-app/app/[locale]/share/location/[id]/page.tsx similarity index 100% rename from app/[locale]/share/location/[id]/page.tsx rename to web-app/app/[locale]/share/location/[id]/page.tsx diff --git a/app/[locale]/share/proof-of-payment/combined/[id]/not-found.tsx b/web-app/app/[locale]/share/proof-of-payment/combined/[id]/not-found.tsx similarity index 100% rename from app/[locale]/share/proof-of-payment/combined/[id]/not-found.tsx rename to web-app/app/[locale]/share/proof-of-payment/combined/[id]/not-found.tsx diff --git a/app/[locale]/share/proof-of-payment/combined/[id]/route.tsx b/web-app/app/[locale]/share/proof-of-payment/combined/[id]/route.tsx similarity index 100% rename from app/[locale]/share/proof-of-payment/combined/[id]/route.tsx rename to web-app/app/[locale]/share/proof-of-payment/combined/[id]/route.tsx diff --git a/app/[locale]/share/proof-of-payment/per-bill/[id]/not-found.tsx b/web-app/app/[locale]/share/proof-of-payment/per-bill/[id]/not-found.tsx similarity index 100% rename from app/[locale]/share/proof-of-payment/per-bill/[id]/not-found.tsx rename to web-app/app/[locale]/share/proof-of-payment/per-bill/[id]/not-found.tsx diff --git a/app/[locale]/share/proof-of-payment/per-bill/[id]/route.tsx b/web-app/app/[locale]/share/proof-of-payment/per-bill/[id]/route.tsx similarity index 100% rename from app/[locale]/share/proof-of-payment/per-bill/[id]/route.tsx rename to web-app/app/[locale]/share/proof-of-payment/per-bill/[id]/route.tsx diff --git a/app/[locale]/terms-of-service/page.tsx b/web-app/app/[locale]/terms-of-service/page.tsx similarity index 100% rename from app/[locale]/terms-of-service/page.tsx rename to web-app/app/[locale]/terms-of-service/page.tsx diff --git a/app/api/auth/[...nextauth]/route.ts b/web-app/app/api/auth/[...nextauth]/route.ts similarity index 100% rename from app/api/auth/[...nextauth]/route.ts rename to web-app/app/api/auth/[...nextauth]/route.ts diff --git a/app/apple-icon.png b/web-app/app/apple-icon.png similarity index 100% rename from app/apple-icon.png rename to web-app/app/apple-icon.png diff --git a/app/favicon.png b/web-app/app/favicon.png similarity index 100% rename from app/favicon.png rename to web-app/app/favicon.png diff --git a/app/i18n.ts b/web-app/app/i18n.ts similarity index 100% rename from app/i18n.ts rename to web-app/app/i18n.ts diff --git a/app/icon1.png b/web-app/app/icon1.png similarity index 100% rename from app/icon1.png rename to web-app/app/icon1.png diff --git a/app/icon2.png b/web-app/app/icon2.png similarity index 100% rename from app/icon2.png rename to web-app/app/icon2.png diff --git a/app/icon3.png b/web-app/app/icon3.png similarity index 100% rename from app/icon3.png rename to web-app/app/icon3.png diff --git a/app/icon4.png b/web-app/app/icon4.png similarity index 100% rename from app/icon4.png rename to web-app/app/icon4.png diff --git a/app/icon5.png b/web-app/app/icon5.png similarity index 100% rename from app/icon5.png rename to web-app/app/icon5.png diff --git a/app/icon6.png b/web-app/app/icon6.png similarity index 100% rename from app/icon6.png rename to web-app/app/icon6.png diff --git a/app/lib/actions/billActions.ts b/web-app/app/lib/actions/billActions.ts similarity index 100% rename from app/lib/actions/billActions.ts rename to web-app/app/lib/actions/billActions.ts diff --git a/web-app/app/lib/actions/emailActions.ts b/web-app/app/lib/actions/emailActions.ts new file mode 100644 index 0000000..a3ce652 --- /dev/null +++ b/web-app/app/lib/actions/emailActions.ts @@ -0,0 +1,175 @@ +'use server'; + +import { getDbClient } from '../dbClient'; +import { BillingLocation, EmailStatus } from '../db-types'; +import { extractShareId, validateShareChecksum } from '../shareChecksum'; +import { revalidatePath } from 'next/cache'; + +export type EmailActionResult = { + success: boolean; + message?: string; +}; + +/** + * Verify tenant email address + * Updates the email status to Verified for the location and all subsequent matching locations + * + * @param shareId - The share ID from the verification link (locationId + checksum) + * @returns Result indicating success or failure + */ +export async function verifyTenantEmail(shareId: string): Promise { + // Extract and validate share ID + const extracted = extractShareId(shareId); + if (!extracted) { + return { + success: false, + message: 'Invalid verification link' + }; + } + + const { locationId, checksum } = extracted; + + // Validate checksum + if (!validateShareChecksum(locationId, checksum)) { + return { + success: false, + message: 'Invalid verification link' + }; + } + + // Get database client + const dbClient = await getDbClient(); + + // Fetch the location to get userId, name, tenantEmail, and yearMonth + const location = await dbClient.collection("lokacije") + .findOne( + { _id: locationId }, + { projection: { userId: 1, name: 1, tenantEmail: 1, yearMonth: 1 } } + ); + + if (!location) { + return { + success: false, + message: 'Location not found' + }; + } + + if (!location.tenantEmail) { + return { + success: false, + message: 'No tenant email configured for this location' + }; + } + + // Update current and all subsequent matching locations + // Match by: userId, name, tenantEmail, and yearMonth >= current + const result = await dbClient.collection("lokacije").updateMany( + { + userId: location.userId, + name: location.name, + tenantEmail: location.tenantEmail, + $or: [ + { "yearMonth.year": { $gt: location.yearMonth.year } }, + { + "yearMonth.year": location.yearMonth.year, + "yearMonth.month": { $gte: location.yearMonth.month } + } + ] + }, + { + $set: { + tenantEmailStatus: EmailStatus.Verified + } + } + ); + + // Revalidate paths to refresh UI + revalidatePath('/[locale]', 'layout'); + + return { + success: true, + message: `Email verified successfully (${result.modifiedCount} location(s) updated)` + }; +} + +/** + * Unsubscribe tenant from email notifications + * Updates the email status to Unsubscribed for the location and all subsequent matching locations + * + * @param shareId - The share ID from the unsubscribe link (locationId + checksum) + * @returns Result indicating success or failure + */ +export async function unsubscribeTenantEmail(shareId: string): Promise { + // Extract and validate share ID + const extracted = extractShareId(shareId); + if (!extracted) { + return { + success: false, + message: 'Invalid unsubscribe link' + }; + } + + const { locationId, checksum } = extracted; + + // Validate checksum + if (!validateShareChecksum(locationId, checksum)) { + return { + success: false, + message: 'Invalid unsubscribe link' + }; + } + + // Get database client + const dbClient = await getDbClient(); + + // Fetch the location to get userId, name, tenantEmail, and yearMonth + const location = await dbClient.collection("lokacije") + .findOne( + { _id: locationId }, + { projection: { userId: 1, name: 1, tenantEmail: 1, yearMonth: 1 } } + ); + + if (!location) { + return { + success: false, + message: 'Location not found' + }; + } + + if (!location.tenantEmail) { + return { + success: false, + message: 'No tenant email configured for this location' + }; + } + + // Update current and all subsequent matching locations + // Match by: userId, name, tenantEmail, and yearMonth >= current + const result = await dbClient.collection("lokacije").updateMany( + { + userId: location.userId, + name: location.name, + tenantEmail: location.tenantEmail, + $or: [ + { "yearMonth.year": { $gt: location.yearMonth.year } }, + { + "yearMonth.year": location.yearMonth.year, + "yearMonth.month": { $gte: location.yearMonth.month } + } + ] + }, + { + $set: { + tenantEmailStatus: EmailStatus.Unsubscribed + } + } + ); + + // Revalidate paths to refresh UI + revalidatePath('/[locale]', 'layout'); + + return { + success: true, + message: `Unsubscribed successfully (${result.modifiedCount} location(s) updated)` + }; +} diff --git a/app/lib/actions/locationActions.ts b/web-app/app/lib/actions/locationActions.ts similarity index 93% rename from app/lib/actions/locationActions.ts rename to web-app/app/lib/actions/locationActions.ts index 489b787..5d124ab 100644 --- a/app/lib/actions/locationActions.ts +++ b/web-app/app/lib/actions/locationActions.ts @@ -2,7 +2,7 @@ import { z } from 'zod'; import { getDbClient } from '../dbClient'; -import { BillingLocation, FileAttachment, YearMonth } from '../db-types'; +import { BillingLocation, FileAttachment, YearMonth, EmailStatus } from '../db-types'; import { ObjectId } from 'mongodb'; import { withUser } from '@/app/lib/auth'; import { AuthenticatedUser } from '../types/next-auth'; @@ -10,7 +10,7 @@ import { gotoHomeWithMessage } from './navigationActions'; import { unstable_noStore, revalidatePath } from 'next/cache'; import { IntlTemplateFn } from '@/app/i18n'; import { getTranslations, getLocale } from "next-intl/server"; -import { generateShareId, extractShareId, validateShareChecksum } from '../shareChecksum'; +import { extractShareId, validateShareChecksum, generateShareChecksum } from '../shareChecksum'; import { validatePdfFile } from '../validators/pdfValidator'; import { checkUploadRateLimit } from '../uploadRateLimiter'; @@ -22,6 +22,7 @@ export type State = { tenantTown?: string[]; autoBillFwd?: string[]; tenantEmail?: string[]; + tenantEmailStatus?: string[]; billFwdStrategy?: string[]; rentDueNotification?: string[]; rentDueDay?: string[]; @@ -44,6 +45,7 @@ const FormSchema = (t:IntlTemplateFn) => z.object({ tenantTown: z.string().max(27).optional().nullable(), autoBillFwd: z.boolean().optional().nullable(), tenantEmail: z.string().email(t("tenant-email-invalid")).optional().or(z.literal("")).nullable(), + tenantEmailStatus: z.enum([EmailStatus.Unverified, EmailStatus.VerificationPending, EmailStatus.Verified, EmailStatus.Unsubscribed]).optional().nullable(), billFwdStrategy: z.enum(["when-payed", "when-attached"]).optional().nullable(), rentDueNotification: z.boolean().optional().nullable(), rentDueDay: z.coerce.number().min(1).max(31).optional().nullable(), @@ -122,6 +124,7 @@ export const updateOrAddLocation = withUser(async (user:AuthenticatedUser, locat tenantTown: formData.get('tenantTown') || null, autoBillFwd: formData.get('autoBillFwd') === 'on', tenantEmail: formData.get('tenantEmail') || null, + tenantEmailStatus: formData.get('tenantEmailStatus') as "unverified" | "verification-pending" | "verified" | "unsubscribed" | undefined, billFwdStrategy: formData.get('billFwdStrategy') as "when-payed" | "when-attached" | undefined, rentDueNotification: formData.get('rentDueNotification') === 'on', rentDueDay: formData.get('rentDueDay') || null, @@ -147,6 +150,7 @@ export const updateOrAddLocation = withUser(async (user:AuthenticatedUser, locat tenantTown, autoBillFwd, tenantEmail, + tenantEmailStatus, billFwdStrategy, rentDueNotification, rentDueDay, @@ -172,6 +176,20 @@ export const updateOrAddLocation = withUser(async (user:AuthenticatedUser, locat }; } + // SECURITY: Validate email status transitions + // - If email changed: force to Unverified (prevents spoofing verified status) + // - If email unchanged: only allow client to reset to Unverified (via reset button) + // All other status transitions (Unverified→VerificationPending, VerificationPending→Verified) + // must happen server-side through other mechanisms (email verification links, etc.) + const emailHasChanged = currentLocation.tenantEmail !== (tenantEmail || null); + const clientWantsToReset = tenantEmailStatus === EmailStatus.Unverified; + + const finalEmailStatus = emailHasChanged + ? EmailStatus.Unverified // Email changed: force reset + : clientWantsToReset + ? EmailStatus.Unverified // Client initiated reset: allow it + : (currentLocation.tenantEmailStatus || EmailStatus.Unverified); // Otherwise: keep current status + // Handle different update scopes if (updateScope === "current" || !updateScope) { // Update only the current location (default behavior) @@ -190,6 +208,7 @@ export const updateOrAddLocation = withUser(async (user:AuthenticatedUser, locat tenantTown: tenantTown || null, autoBillFwd: autoBillFwd || false, tenantEmail: tenantEmail || null, + tenantEmailStatus: finalEmailStatus, billFwdStrategy: billFwdStrategy || "when-payed", rentDueNotification: rentDueNotification || false, rentDueDay: rentDueDay || null, @@ -221,6 +240,7 @@ export const updateOrAddLocation = withUser(async (user:AuthenticatedUser, locat tenantTown: tenantTown || null, autoBillFwd: autoBillFwd || false, tenantEmail: tenantEmail || null, + tenantEmailStatus: finalEmailStatus, billFwdStrategy: billFwdStrategy || "when-payed", rentDueNotification: rentDueNotification || false, rentDueDay: rentDueDay || null, @@ -245,6 +265,7 @@ export const updateOrAddLocation = withUser(async (user:AuthenticatedUser, locat tenantTown: tenantTown || null, autoBillFwd: autoBillFwd || false, tenantEmail: tenantEmail || null, + tenantEmailStatus: finalEmailStatus, billFwdStrategy: billFwdStrategy || "when-payed", rentDueNotification: rentDueNotification || false, rentDueDay: rentDueDay || null, @@ -268,6 +289,7 @@ export const updateOrAddLocation = withUser(async (user:AuthenticatedUser, locat tenantTown: tenantTown || null, autoBillFwd: autoBillFwd || false, tenantEmail: tenantEmail || null, + tenantEmailStatus: tenantEmailStatus as EmailStatus || EmailStatus.Unverified, billFwdStrategy: billFwdStrategy || "when-payed", rentDueNotification: rentDueNotification || false, rentDueDay: rentDueDay || null, @@ -343,6 +365,7 @@ export const updateOrAddLocation = withUser(async (user:AuthenticatedUser, locat tenantTown: tenantTown || null, autoBillFwd: autoBillFwd || false, tenantEmail: tenantEmail || null, + tenantEmailStatus: tenantEmailStatus as EmailStatus || EmailStatus.Unverified, billFwdStrategy: billFwdStrategy || "when-payed", rentDueNotification: rentDueNotification || false, rentDueDay: rentDueDay || null, @@ -446,6 +469,8 @@ export const fetchAllLocations = withUser(async (user:AuthenticatedUser, year:nu "bills.payedAmount": 1, "bills.proofOfPayment.uploadedAt": 1, "seenByTenantAt": 1, + "tenantEmail": 1, + "tenantEmailStatus": 1, // "bills.attachment": 0, // "bills.notes": 0, // "bills.hub3aText": 1, @@ -740,6 +765,18 @@ export const uploadUtilBillsProofOfPayment = async ( } } + +/** + * Generate combined location ID with checksum appended + * @param locationId - The MongoDB location ID (24 chars) + * @returns Combined ID: locationId + checksum (40 chars total) + */ +export async function generateShareId(locationId: string): Promise { + const checksum = generateShareChecksum(locationId); + return locationId + checksum; +} + + /** * Generate/activate share link for location * Called when owner clicks "Share" button @@ -775,7 +812,7 @@ export const generateShareLink = withUser( ); // Generate combined share ID (locationId + checksum) - const shareId = generateShareId(locationId); + const shareId = await generateShareId(locationId); // Build share URL const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000'; diff --git a/app/lib/actions/monthActions.ts b/web-app/app/lib/actions/monthActions.ts similarity index 100% rename from app/lib/actions/monthActions.ts rename to web-app/app/lib/actions/monthActions.ts diff --git a/app/lib/actions/navigationActions.ts b/web-app/app/lib/actions/navigationActions.ts similarity index 100% rename from app/lib/actions/navigationActions.ts rename to web-app/app/lib/actions/navigationActions.ts diff --git a/app/lib/actions/printActions.ts b/web-app/app/lib/actions/printActions.ts similarity index 100% rename from app/lib/actions/printActions.ts rename to web-app/app/lib/actions/printActions.ts diff --git a/app/lib/actions/userSettingsActions.ts b/web-app/app/lib/actions/userSettingsActions.ts similarity index 100% rename from app/lib/actions/userSettingsActions.ts rename to web-app/app/lib/actions/userSettingsActions.ts diff --git a/app/lib/asyncTimeout.ts b/web-app/app/lib/asyncTimeout.ts similarity index 100% rename from app/lib/asyncTimeout.ts rename to web-app/app/lib/asyncTimeout.ts diff --git a/app/lib/auth.ts b/web-app/app/lib/auth.ts similarity index 100% rename from app/lib/auth.ts rename to web-app/app/lib/auth.ts diff --git a/app/lib/db-types.ts b/web-app/app/lib/db-types.ts similarity index 85% rename from app/lib/db-types.ts rename to web-app/app/lib/db-types.ts index 87a93ef..a21b62e 100644 --- a/app/lib/db-types.ts +++ b/web-app/app/lib/db-types.ts @@ -1,3 +1,4 @@ +import { unsubscribe } from "diagnostics_channel"; export interface FileAttachment { fileName: string; @@ -35,6 +36,17 @@ export interface UserSettings { ownerRevolutProfileName?: string | null; }; +export enum EmailStatus { + /** Email is not yet verified - recipient has not yet confirmed their email address */ + Unverified = "unverified", + /** Email is not yet verified - a verification request has been sent */ + VerificationPending = "verification-pending", + /** Email is verified and is in good standing: emails are being successfully delivered */ + Verified = "verified", + /** Recepient has unsubscribed from receiving emails via link - no further emails will be sent */ + Unsubscribed = "unsubscribed" +} + /** bill object in the form returned by MongoDB */ export interface BillingLocation { _id: string; @@ -67,6 +79,8 @@ export interface BillingLocation { autoBillFwd?: boolean | null; /** (optional) tenant email */ tenantEmail?: string | null; + /** (optional) tenant email status */ + tenantEmailStatus?: EmailStatus | null; /** (optional) bill forwarding strategy */ billFwdStrategy?: "when-payed" | "when-attached" | null; /** (optional) whether to automatically send rent notification */ diff --git a/app/lib/dbClient.ts b/web-app/app/lib/dbClient.ts similarity index 100% rename from app/lib/dbClient.ts rename to web-app/app/lib/dbClient.ts diff --git a/app/lib/format.ts b/web-app/app/lib/format.ts similarity index 100% rename from app/lib/format.ts rename to web-app/app/lib/format.ts diff --git a/app/lib/formatStrings.ts b/web-app/app/lib/formatStrings.ts similarity index 100% rename from app/lib/formatStrings.ts rename to web-app/app/lib/formatStrings.ts diff --git a/app/lib/getProviders.ts b/web-app/app/lib/getProviders.ts similarity index 100% rename from app/lib/getProviders.ts rename to web-app/app/lib/getProviders.ts diff --git a/app/lib/global.d.ts b/web-app/app/lib/global.d.ts similarity index 100% rename from app/lib/global.d.ts rename to web-app/app/lib/global.d.ts diff --git a/app/lib/paragraphFormatFactory.tsx b/web-app/app/lib/paragraphFormatFactory.tsx similarity index 86% rename from app/lib/paragraphFormatFactory.tsx rename to web-app/app/lib/paragraphFormatFactory.tsx index 13883da..3271eb4 100644 --- a/app/lib/paragraphFormatFactory.tsx +++ b/web-app/app/lib/paragraphFormatFactory.tsx @@ -6,7 +6,7 @@ export const paragraphFormatFactory = (locale: string) => ({ bold: (chunks: ReactNode) => {chunks}, indigo: (chunks: ReactNode) => {chunks} , p: (chunks: ReactNode) =>

{chunks}

, - disclaimer: (chunks: ReactNode) =>

{chunks}

, + disclaimer: (chunks: ReactNode) =>

{chunks}

, hint: (chunks: ReactNode) => {chunks}, linkTermsOfService: (chunks: ReactNode) => {chunks}, linkPrivacyPolicy: (chunks: ReactNode) => {chunks} diff --git a/app/lib/pdf/barcodeDecoderWasm.ts b/web-app/app/lib/pdf/barcodeDecoderWasm.ts similarity index 100% rename from app/lib/pdf/barcodeDecoderWasm.ts rename to web-app/app/lib/pdf/barcodeDecoderWasm.ts diff --git a/app/lib/pdf/bcmath.ts b/web-app/app/lib/pdf/bcmath.ts similarity index 100% rename from app/lib/pdf/bcmath.ts rename to web-app/app/lib/pdf/bcmath.ts diff --git a/app/lib/pdf/pdf417.ts b/web-app/app/lib/pdf/pdf417.ts similarity index 100% rename from app/lib/pdf/pdf417.ts rename to web-app/app/lib/pdf/pdf417.ts diff --git a/app/lib/pdf/pdf417LookupTables.ts b/web-app/app/lib/pdf/pdf417LookupTables.ts similarity index 100% rename from app/lib/pdf/pdf417LookupTables.ts rename to web-app/app/lib/pdf/pdf417LookupTables.ts diff --git a/app/lib/pdf/renderBarcode.ts b/web-app/app/lib/pdf/renderBarcode.ts similarity index 100% rename from app/lib/pdf/renderBarcode.ts rename to web-app/app/lib/pdf/renderBarcode.ts diff --git a/app/lib/shareChecksum.ts b/web-app/app/lib/shareChecksum.ts similarity index 86% rename from app/lib/shareChecksum.ts rename to web-app/app/lib/shareChecksum.ts index aef9874..0e5c2cc 100644 --- a/app/lib/shareChecksum.ts +++ b/web-app/app/lib/shareChecksum.ts @@ -56,16 +56,6 @@ export function validateShareChecksum( } } -/** - * Generate combined location ID with checksum appended - * @param locationId - The MongoDB location ID (24 chars) - * @returns Combined ID: locationId + checksum (40 chars total) - */ -export function generateShareId(locationId: string): string { - const checksum = generateShareChecksum(locationId); - return locationId + checksum; -} - /** * Extract location ID and checksum from combined share ID * @param shareId - Combined ID (locationId + checksum) diff --git a/app/lib/types/next-auth.d.ts b/web-app/app/lib/types/next-auth.d.ts similarity index 100% rename from app/lib/types/next-auth.d.ts rename to web-app/app/lib/types/next-auth.d.ts diff --git a/app/lib/uploadRateLimiter.ts b/web-app/app/lib/uploadRateLimiter.ts similarity index 100% rename from app/lib/uploadRateLimiter.ts rename to web-app/app/lib/uploadRateLimiter.ts diff --git a/app/lib/validators/pdfValidator.ts b/web-app/app/lib/validators/pdfValidator.ts similarity index 100% rename from app/lib/validators/pdfValidator.ts rename to web-app/app/lib/validators/pdfValidator.ts diff --git a/app/manifest.webmanifest b/web-app/app/manifest.webmanifest similarity index 100% rename from app/manifest.webmanifest rename to web-app/app/manifest.webmanifest diff --git a/app/ui/AddLocationButton.tsx b/web-app/app/ui/AddLocationButton.tsx similarity index 100% rename from app/ui/AddLocationButton.tsx rename to web-app/app/ui/AddLocationButton.tsx diff --git a/app/ui/AddMonthButton.tsx b/web-app/app/ui/AddMonthButton.tsx similarity index 100% rename from app/ui/AddMonthButton.tsx rename to web-app/app/ui/AddMonthButton.tsx diff --git a/web-app/app/ui/AsyncLink.tsx b/web-app/app/ui/AsyncLink.tsx new file mode 100644 index 0000000..ece2643 --- /dev/null +++ b/web-app/app/ui/AsyncLink.tsx @@ -0,0 +1,13 @@ +import React from "react"; +import Link from "next/link"; + +/** Link component that can be disabled */ +export const AsyncLink: React.FC<{ href: string; children: React.ReactNode, target?: string, className?: string, disabled?: boolean }> = ({ href, children, target, className, disabled }) => + disabled ? {children} : + + {children} + diff --git a/app/ui/BillBadge.tsx b/web-app/app/ui/BillBadge.tsx similarity index 100% rename from app/ui/BillBadge.tsx rename to web-app/app/ui/BillBadge.tsx diff --git a/app/ui/BillDeleteForm.tsx b/web-app/app/ui/BillDeleteForm.tsx similarity index 100% rename from app/ui/BillDeleteForm.tsx rename to web-app/app/ui/BillDeleteForm.tsx diff --git a/app/ui/BillEditForm.tsx b/web-app/app/ui/BillEditForm.tsx similarity index 94% rename from app/ui/BillEditForm.tsx rename to web-app/app/ui/BillEditForm.tsx index 846c400..edcb2f9 100644 --- a/app/ui/BillEditForm.tsx +++ b/web-app/app/ui/BillEditForm.tsx @@ -2,7 +2,7 @@ import { DocumentIcon, TicketIcon, TrashIcon } from "@heroicons/react/24/outline"; import { Bill, BilledTo, BillingLocation } from "../lib/db-types"; -import React, { FC, useEffect } from "react"; +import React, { FC, useEffect, useState } from "react"; import { useFormState } from "react-dom"; import { updateOrAddBill } from "../lib/actions/billActions"; import Link from "next/link"; @@ -11,6 +11,8 @@ import { DecodeResult, findDecodePdf417 } from "../lib/pdf/barcodeDecoderWasm"; import { useLocale, useTranslations } from "next-intl"; import { InfoBox } from "./InfoBox"; import { Pdf417Barcode } from "./Pdf417Barcode"; +import { generateShareId } from "../lib/actions/locationActions"; +import { AsyncLink } from "./AsyncLink"; // Next.js does not encode an utf-8 file name correctly when sending a form with a file attachment // This is a workaround for that @@ -35,6 +37,20 @@ export const BillEditForm: FC = ({ location, bill }) => { const { yearMonth: { year: billYear, month: billMonth }, _id: locationID, proofOfPaymentType } = location; + /** + * Share ID for viewing-only links (locationID + checksum) + * Note: This is different from the share button which calls `generateShareLink` + * to activate sharing and set TTL in the database + */ + const [shareID, setShareID] = useState(null); + + useEffect(() => { + // share ID can be generated server-side since it requires a secret key + // which we don't want to expose to the client + (async () => setShareID(await generateShareId(locationID)))(); + }, [locationID]); + + const initialState = { message: null, errors: {} }; const handleAction = updateOrAddBillMiddleware.bind(null, locationID, billID, billYear, billMonth); @@ -238,14 +254,15 @@ export const BillEditForm: FC = ({ location, bill }) => { // -> don't show anything proofOfPayment.fileName ? (
- {decodeURIComponent(proofOfPayment.fileName)} - +
) : null } diff --git a/app/ui/EnterOrSignInButton.tsx b/web-app/app/ui/EnterOrSignInButton.tsx similarity index 61% rename from app/ui/EnterOrSignInButton.tsx rename to web-app/app/ui/EnterOrSignInButton.tsx index aa31a44..ba2083f 100644 --- a/app/ui/EnterOrSignInButton.tsx +++ b/web-app/app/ui/EnterOrSignInButton.tsx @@ -14,9 +14,10 @@ export const EnterOrSignInButton: FC<{ session: any, locale: string, providers: return ( <> - - { - session ? ( + { + session ? ( + + {t("main-card.go-to-app")} - ) : ( - Object.values(providers).map((provider) => ( -
- -
- )) - ) - } -
- {t.rich("disclaimer", paragraphFormat)} +
+ + ) : ( + <> + + + { + Object.values(providers).map((provider) => ( +
+ +
+ )) + } +
+ {t.rich("disclaimer", paragraphFormat)} + + ) + } + ); -}; \ No newline at end of file +}; diff --git a/app/ui/HomePage.tsx b/web-app/app/ui/HomePage.tsx similarity index 57% rename from app/ui/HomePage.tsx rename to web-app/app/ui/HomePage.tsx index 2e46fcb..581608d 100644 --- a/app/ui/HomePage.tsx +++ b/web-app/app/ui/HomePage.tsx @@ -1,9 +1,8 @@ import { fetchAllLocations } from '@/app/lib/actions/locationActions'; import { fetchAvailableYears } from '@/app/lib/actions/monthActions'; import { getUserSettings } from '@/app/lib/actions/userSettingsActions'; -import { BillingLocation, YearMonth } from '@/app/lib/db-types'; import { FC } from 'react'; -import { MonthLocationList } from '@/app/ui/MonthLocationList'; +import { MonthArray, MonthLocationList } from '@/app/ui/MonthLocationList'; import { ParamsYearInvalidMessage } from './ParamsYearInvalidMessage'; export interface HomePageProps { @@ -46,51 +45,59 @@ export const HomePage:FC = async ({ searchParams }) => { } selectedYear = paramsYear; + } else { const currYear = new Date().getFullYear(); - // IF current year is available in DB THEN use it - // ELSE use the latest year found in the DB - selectedYear = availableYears.includes(currYear) ? currYear : availableYears[0]; + if(availableYears.length === 0) { + // Database is in it's initial state + // so just set selected year to current year + selectedYear = currYear; + } else { + // IF current year is available in DB THEN use it + // ELSE use the latest year found in the DB + selectedYear = availableYears.includes(currYear) ? currYear : availableYears[0]; + } } const locations = await fetchAllLocations(selectedYear); const userSettings = await getUserSettings(); - // group locations by month - const months = locations.reduce((acc, location) => { + // Create months object by grouping locations by yearMonth + const { months } = locations.reduce((acc, location) => { const {year, month} = location.yearMonth; const key = `${year}-${month}`; - const locationsInMonth = acc[key]; + const monthIx = acc.index[key]; - if(locationsInMonth) { - return({ - ...acc, - [key]: { - yearMonth: location.yearMonth, - locations: [...locationsInMonth.locations, location], - unpaidTotal: locationsInMonth.unpaidTotal + location.bills.reduce((acc, bill) => !bill.paid ? acc + (bill.payedAmount ?? 0) : acc, 0), - payedTotal: locationsInMonth.payedTotal + location.bills.reduce((acc, bill) => bill.paid ? acc + (bill.payedAmount ?? 0) : acc, 0) - } - }) + if(monthIx) { + + const existingMonth = acc.months[monthIx]; + + existingMonth.locations.push(location); + existingMonth.unpaidTotal += location.bills.reduce((acc, bill) => !bill.paid ? acc + (bill.payedAmount ?? 0) : acc, 0); + existingMonth.payedTotal += location.bills.reduce((acc, bill) => bill.paid ? acc + (bill.payedAmount ?? 0) : acc, 0); + + return acc; } - return({ - ...acc, - [key]: { - yearMonth: location.yearMonth, - locations: [location], - unpaidTotal: location.bills.reduce((acc, bill) => !bill.paid ? acc + (bill.payedAmount ?? 0) : acc, 0), - payedTotal: location.bills.reduce((acc, bill) => bill.paid ? acc + (bill.payedAmount ?? 0) : acc, 0) - } + acc.months.push({ + yearMonth: location.yearMonth, + locations: [location], + unpaidTotal: location.bills.reduce((acc, bill) => !bill.paid ? acc + (bill.payedAmount ?? 0) : acc, 0), + payedTotal: location.bills.reduce((acc, bill) => bill.paid ? acc + (bill.payedAmount ?? 0) : acc, 0) }); - }, {} as {[key:string]:{ - yearMonth: YearMonth, - locations: BillingLocation[], - unpaidTotal: number, - payedTotal: number - } }); + + acc.index[key] = acc.months.length - 1; + + return acc; + }, { + index: {}, + months: [] + } as { + index: Record, + months: MonthArray + }); return ( diff --git a/app/ui/InfoBox.tsx b/web-app/app/ui/InfoBox.tsx similarity index 93% rename from app/ui/InfoBox.tsx rename to web-app/app/ui/InfoBox.tsx index 6dc95f7..491d296 100644 --- a/app/ui/InfoBox.tsx +++ b/web-app/app/ui/InfoBox.tsx @@ -17,7 +17,7 @@ export const InfoBox: FC<{ -
{children}
+
{children}
) } diff --git a/app/ui/LocationCard.tsx b/web-app/app/ui/LocationCard.tsx similarity index 68% rename from app/ui/LocationCard.tsx rename to web-app/app/ui/LocationCard.tsx index f175f20..914db54 100644 --- a/app/ui/LocationCard.tsx +++ b/web-app/app/ui/LocationCard.tsx @@ -1,15 +1,16 @@ 'use client'; -import { CheckCircleIcon, Cog8ToothIcon, PlusCircleIcon, ShareIcon, BanknotesIcon, EyeIcon, TicketIcon, ShoppingCartIcon } from "@heroicons/react/24/outline"; -import { FC } from "react"; +import { CheckCircleIcon, Cog8ToothIcon, PlusCircleIcon, ShareIcon, BanknotesIcon, EyeIcon, TicketIcon, ShoppingCartIcon, EnvelopeIcon, ExclamationTriangleIcon, ClockIcon } from "@heroicons/react/24/outline"; +import { FC, useEffect, useState } from "react"; import { BillBadge } from "./BillBadge"; -import { BillingLocation } from "../lib/db-types"; +import { BillingLocation, EmailStatus } from "../lib/db-types"; import { formatYearMonth } from "../lib/format"; import { formatCurrency } from "../lib/formatStrings"; import Link from "next/link"; -import { useLocale, useTranslations } from "next-intl"; +import { useTranslations } from "next-intl"; import { toast } from "react-toastify"; -import { generateShareLink } from "../lib/actions/locationActions"; +import { generateShareId, generateShareLink } from "../lib/actions/locationActions"; +import { AsyncLink } from "./AsyncLink"; export interface LocationCardProps { location: BillingLocation; @@ -25,15 +26,28 @@ export const LocationCard: FC = ({ location, currency }) => { seenByTenantAt, // NOTE: only the fileName is projected from the DB to reduce data transfer utilBillsProofOfPayment, + tenantEmail, + tenantEmailStatus, } = location; const t = useTranslations("home-page.location-card"); - const currentLocale = useLocale(); // sum all the unpaid and paid bill amounts (regardless of who pays) const totalUnpaid = bills.reduce((acc, bill) => !bill.paid ? acc + (bill.payedAmount ?? 0) : acc, 0); const totalPayed = bills.reduce((acc, bill) => bill.paid ? acc + (bill.payedAmount ?? 0) : acc, 0); + /** + * Share ID which can be used in shareable links + * Note: not to be used in share button directly since `generateShareLink` sets sharing TTL in the DB + * */ + const [shareID, setShareID] = useState("not-yet-generated"); + + useEffect(() => { + // share ID can be generated server-side since it requires a secret key + // which we don't want to expose to the client + (async () => setShareID(await generateShareId(_id)))(); + }, [_id]); + const handleCopyLinkClick = async () => { // copy URL to clipboard const shareLink = await generateShareLink(_id); @@ -69,7 +83,7 @@ export const LocationCard: FC = ({ location, currency }) => { - { totalUnpaid > 0 || totalPayed > 0 || seenByTenantAt || utilBillsProofOfPayment?.uploadedAt ? + { totalUnpaid > 0 || totalPayed > 0 || seenByTenantAt || utilBillsProofOfPayment?.uploadedAt || (tenantEmail && tenantEmailStatus && tenantEmailStatus !== EmailStatus.Verified) ? <>
@@ -95,6 +109,24 @@ export const LocationCard: FC = ({ location, currency }) => {
: null } + {tenantEmail && tenantEmailStatus && tenantEmailStatus !== EmailStatus.Verified && ( +
+ + {tenantEmailStatus === EmailStatus.Unverified && } + {tenantEmailStatus === EmailStatus.VerificationPending && } + {tenantEmailStatus === EmailStatus.Unsubscribed && } + + + {tenantEmailStatus === EmailStatus.Unverified && `${t("email-status.unverified")}`} + {tenantEmailStatus === EmailStatus.VerificationPending && `${t("email-status.verification-pending")}`} + {tenantEmailStatus === EmailStatus.Unsubscribed && `${t("email-status.unsubscribed")}`} + +
+ )} {seenByTenantAt && (
@@ -105,16 +137,17 @@ export const LocationCard: FC = ({ location, currency }) => {
)} {utilBillsProofOfPayment?.uploadedAt && ( - + className="flex mt-1 ml-1" + disabled={!shareID} > {t("download-proof-of-payment-label")} - + )} diff --git a/app/ui/LocationDeleteForm.tsx b/web-app/app/ui/LocationDeleteForm.tsx similarity index 100% rename from app/ui/LocationDeleteForm.tsx rename to web-app/app/ui/LocationDeleteForm.tsx diff --git a/app/ui/LocationEditForm.tsx b/web-app/app/ui/LocationEditForm.tsx similarity index 87% rename from app/ui/LocationEditForm.tsx rename to web-app/app/ui/LocationEditForm.tsx index ae05270..3b392d3 100644 --- a/app/ui/LocationEditForm.tsx +++ b/web-app/app/ui/LocationEditForm.tsx @@ -1,8 +1,8 @@ "use client"; -import { TrashIcon } from "@heroicons/react/24/outline"; +import { TrashIcon, ExclamationTriangleIcon, ClockIcon, EnvelopeIcon, CheckCircleIcon, PencilSquareIcon } from "@heroicons/react/24/outline"; import { FC, useState } from "react"; -import { BillingLocation, UserSettings, YearMonth } from "../lib/db-types"; +import { BillingLocation, UserSettings, YearMonth, EmailStatus } from "../lib/db-types"; import { updateOrAddLocation } from "../lib/actions/locationActions"; import { useFormState } from "react-dom"; import Link from "next/link"; @@ -41,6 +41,7 @@ export const LocationEditForm: FC = ({ location, yearMont tenantStreet: location?.tenantStreet ?? "", tenantTown: location?.tenantTown ?? "", tenantEmail: location?.tenantEmail ?? "", + tenantEmailStatus: location?.tenantEmailStatus ?? EmailStatus.Unverified, tenantPaymentMethod: location?.tenantPaymentMethod ?? "none", proofOfPaymentType: location?.proofOfPaymentType ?? "none", autoBillFwd: location?.autoBillFwd ?? false, @@ -50,10 +51,21 @@ export const LocationEditForm: FC = ({ location, yearMont rentDueDay: location?.rentDueDay ?? 1, }); + // tenant e-mail fetched from database + const [dbTenantEmail, setDbTenantEmail] = useState(location?.tenantEmail ?? ""); + const handleInputChange = (field: keyof typeof formValues, value: string | boolean | number) => { setFormValues(prev => ({ ...prev, [field]: value })); }; + const handleResetEmailStatus = () => { + // this will simulate that the email + // is new and needs verification + setDbTenantEmail(""); + // reset the email status to unverified + setFormValues(prev => ({ ...prev, tenantEmailStatus: EmailStatus.Unverified })); + }; + let { year, month } = location ? location.yearMonth : yearMonth; return ( @@ -265,7 +277,6 @@ export const LocationEditForm: FC = ({ location, yearMont