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

This commit is contained in:
2026-01-09 18:38:11 +01:00
192 changed files with 1606 additions and 155 deletions

View File

@@ -20,7 +20,11 @@
"mcp__context7__resolve-library-id", "mcp__context7__resolve-library-id",
"mcp__context7__get-library-docs", "mcp__context7__get-library-docs",
"mcp__serena__create_text_file", "mcp__serena__create_text_file",
"Bash(curl:*)" "Bash(curl:*)",
"Bash(git mv:*)",
"Bash(rmdir:*)",
"Bash(mkdir:*)",
"Bash(git diff:*)"
] ]
}, },
"enableAllProjectMcpServers": true, "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

6
.gitignore vendored
View File

@@ -1,14 +1,16 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies # dependencies
/node_modules node_modules
/.pnp /.pnp
.pnp.js .pnp.js
# testing # testing
/coverage /coverage
# next.js # next.js (in web-app workspace)
web-app/.next/
web-app/out/
/.next/ /.next/
/out/ /out/

View File

@@ -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/), 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). 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 ## [2.17.0] - 2025-12-21
### Changed ### Changed

View File

@@ -2,20 +2,41 @@
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. 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 ## Development Commands
- `npm run dev` - Start development server (Next.js) 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 run build` - Build production version
- `npm start` - Start production server - `npm start` - Start production server
- `npm run prettier` - Format code with Prettier - `npm run prettier` - Format code
- `npm run prettier:check` - Check code formatting
- `npm run seed` - Seed database with initial data - `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 ## Deployment Commands
- `./build.sh` - Build Docker image for deployment **Building Docker Image** (`cd web-app`):
- `./deploy.sh` - Deploy Docker service to production - `./build.sh <version>` - Build Docker image
- `./debug-deploy.sh` - Deploy with debug configuration
**Deploying** (`cd docker-stack`):
- `./deploy-standalone.sh <version>` - Deploy with docker-compose (standalone)
- `./deploy-swarm.sh <version>` - Deploy with Docker Swarm
## Architecture Overview ## Architecture Overview
@@ -31,25 +52,26 @@ This is a Next.js 14 utility bills tracking application ("Evidencija Režija") w
### Core Architecture Patterns ### 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 ```typescript
export const actionName = withUser(async (user: AuthenticatedUser, ...args) => { export const actionName = withUser(async (user: AuthenticatedUser, ...args) => {
// Server action implementation with automatic user context // 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 ### Key Files & Responsibilities
- `middleware.ts` - Handles authentication and i18n routing, defines public pages - `web-app/middleware.ts` - Handles authentication and i18n routing, defines public pages
- `app/lib/auth.ts` - NextAuth configuration, `withUser` HOF for user context - `web-app/app/lib/auth.ts` - NextAuth configuration, `withUser` HOF for user context
- `app/lib/dbClient.ts` - MongoDB connection with development/production handling - `web-app/app/lib/dbClient.ts` - MongoDB connection with development/production handling
- `app/lib/actions/` - Server actions for data mutations (locations, bills, months) - `web-app/app/lib/actions/` - Server actions for data mutations (locations, bills, months)
- `app/i18n.ts` - Internationalization configuration (Croatian default) - `web-app/app/i18n.ts` - Internationalization configuration (Croatian default)
- `next.config.js` - Standalone build config with `serverActions.allowedOrigins` for Docker deployment - `web-app/next.config.js` - Standalone build config with `serverActions.allowedOrigins` for Docker deployment
- `housekeeping/` - Database backup and maintenance scripts
### Database Schema ### Database Schema
- **Collections**: Locations, Bills, Months (year-month periods) - **Collections**: Locations, Bills, Months (year-month periods)

View File

@@ -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. 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 # Database Backup & Restore
The project includes multiple backup strategies for different deployment scenarios and requirements. The project includes multiple backup strategies for different deployment scenarios and requirements.
All backup scripts are located in the `housekeeping/` workspace.
## Backup Scripts Overview ## Backup Scripts Overview
### Standalone Docker Deployments ### Standalone Docker Deployments
**Online Backups (No Downtime):** **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 - Database stays running during backup
- Only backs up the database content, not the full volume - Only backs up the database content, not the full volume
- Output: `./mongo-backup/utility-bills-dump-YYYY-MM-DD_HH-MM.tar.gz` - Output: `./mongo-backup/utility-bills-dump-YYYY-MM-DD_HH-MM.tar.gz`
- Default retention: 7 backups (configurable via `KEEP` env var) - 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 - Database stays running during restore
- **WARNING**: Drops existing collections before 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):** **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 - Database container is stopped during backup for consistency
- Backs up the entire MongoDB data directory - Backs up the entire MongoDB data directory
- Output: `./mongo-backup/mongo-volume-backup-YYYY-MM-DD-HH-MM.tar.gz` - Output: `./mongo-backup/mongo-volume-backup-YYYY-MM-DD-HH-MM.tar.gz`
- Default retention: 7 backups (configurable via `KEEP` env var) - 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 ### 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 - Service is scaled to 0 during backup
- Output: `./mongo-backup/mongo-volume-backup-YYYY-MM-DD-HH-MM.tar.gz` - 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 - Service is scaled to 0 during restore
- Optional `--pre-backup` flag for safety backup before 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 ## Automated Backup Schedule
@@ -64,10 +92,10 @@ Backups run automatically via cron at 04:00 every day:
```cron ```cron
# Sunday: Full volume backup (offline), keep 2 backups # 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 # 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:** **Backup Strategy:**
@@ -89,21 +117,32 @@ All backups are stored in `./mongo-backup/`:
This directory is excluded from git via `.gitignore`. This directory is excluded from git via `.gitignore`.
# Deploying # Deploying
The deployment is done via Docker:
* build docker image
* deploy Docker service
## Building Docker image The deployment is done via Docker.
Run the following command:
## Building Docker Image
From the `web-app/` directory:
```bash ```bash
build.sh cd web-app
./build.sh 2.20.0
``` ```
The image will be stored in the local Docker instance. The image will be stored in the local Docker instance.
## Deploying Docker service ## Deploying Docker Service
Run the following command:
From the `docker-stack/` directory:
```bash ```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 # Implementation details

46
docker-stack/README.md Normal file
View File

@@ -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/`

View File

@@ -18,7 +18,7 @@ services:
- traefik-network - traefik-network
- util-bills-mongo-network - util-bills-mongo-network
volumes: volumes:
- ./etc/hosts/:/etc/hosts - ./web-app/etc/hosts/:/etc/hosts
environment: environment:
MONGODB_URI: mongodb://rezije.app:w4z4piJBgCdAm4tpawqB@mongo:27017/utility-bills MONGODB_URI: mongodb://rezije.app:w4z4piJBgCdAm4tpawqB@mongo:27017/utility-bills
GOOGLE_ID: 355397364527-adjrokm6hromcaaar0qfhk050mfr35ou.apps.googleusercontent.com GOOGLE_ID: 355397364527-adjrokm6hromcaaar0qfhk050mfr35ou.apps.googleusercontent.com

View File

@@ -18,7 +18,7 @@ services:
- traefik-network - traefik-network
- util-bills-mongo-network - util-bills-mongo-network
volumes: volumes:
- ./etc/hosts/:/etc/hosts - ./web-app/etc/hosts/:/etc/hosts
environment: environment:
MONGODB_URI: mongodb://rezije.app:w4z4piJBgCdAm4tpawqB@mongo:27017/utility-bills MONGODB_URI: mongodb://rezije.app:w4z4piJBgCdAm4tpawqB@mongo:27017/utility-bills
GOOGLE_ID: 355397364527-adjrokm6hromcaaar0qfhk050mfr35ou.apps.googleusercontent.com GOOGLE_ID: 355397364527-adjrokm6hromcaaar0qfhk050mfr35ou.apps.googleusercontent.com

10
docker-stack/package.json Normal file
View File

@@ -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"
}
}

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

@@ -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 <support@rezije.app>",
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!",
});
console.log(data); // logs response data
} catch (error) {
console.log(error); //logs any error
}
}
sendSimpleMessage();

View File

@@ -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"
]
}
}

20
housekeeping/README.md Normal file
View File

@@ -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
```

13
housekeeping/package.json Normal file
View File

@@ -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"
}
}

23
mailgun-webhook/README.md Normal file
View File

@@ -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

View File

@@ -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 (`<div className="flex ml-1">`)
## E-mail status in `LocationEditForm.tsx`
Current e-mail status will be indicated as a sibling of:
```
{/* Email status indicator should go here */}
<div id="tenantEmail-error" aria-live="polite" aria-atomic="true">
...
```
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.

View File

View File

@@ -8,7 +8,7 @@
"name": "Debug", "name": "Debug",
"type": "node", "type": "node",
"request": "launch", "request": "launch",
"envFile": "${workspaceFolder}/.env", "envFile": "${workspaceFolder:🌐 web-app}/.env",
"env": { "env": {
"USE_MOCK_AUTH": "true", "USE_MOCK_AUTH": "true",
"MOCK_USER_ID": "109754742613069927799", "MOCK_USER_ID": "109754742613069927799",
@@ -19,8 +19,8 @@
"dev" // this is `dev` from `npm run dev` "dev" // this is `dev` from `npm run dev`
], ],
"runtimeExecutable": "npm", "runtimeExecutable": "npm",
"cwd": "${workspaceFolder}/", "cwd": "${workspaceFolder:🌐 web-app}/",
"localRoot": "${workspaceFolder}/", "localRoot": "${workspaceFolder:🌐 web-app}/",
"remoteRoot": "/app/", "remoteRoot": "/app/",
"skipFiles": [ "skipFiles": [
"<node_internals>/**", "<node_internals>/**",

View File

@@ -13,7 +13,7 @@
"build": true, "build": true,
}, },
"files": [ "files": [
"${workspaceFolder}/docker-compose-debug.yml" "${workspaceFolder:🌐 web-app}/docker-compose-debug.yml"
], ],
"isBackground": true, "isBackground": true,
"problemMatcher": "Terminal will be reused by tasks, press any key to close it" "problemMatcher": "Terminal will be reused by tasks, press any key to close it"
@@ -25,7 +25,7 @@
"dockerCompose": { "dockerCompose": {
"down": {}, "down": {},
"files": [ "files": [
"${workspaceFolder}/docker-compose-debug.yml" "${workspaceFolder:🌐 web-app}/docker-compose-debug.yml"
], ],
} }
}, },

View File

@@ -12,16 +12,16 @@ RUN apk add --no-cache libc6-compat
WORKDIR /app WORKDIR /app
# package.json and package-lock.json # Copy package files
COPY ./package.json ./package-lock.json ./ COPY ./package.json ./package-lock.json ./
# installing dependencies # Install dependencies
RUN npm i && npm cache clean --force RUN npm i && npm cache clean --force
# copy all the soruce code # Copy application source code
COPY . . COPY . .
# building app # Build application
RUN npm run build RUN npm run build
#----------------------------------------- #-----------------------------------------

View File

@@ -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<string | null>(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 (
<div className="card bg-base-100 shadow-xl max-w-2xl mx-auto mt-8">
<div className="card-body">
<div className="flex justify-center mb-4">
<CheckCircleIcon className="h-16 w-16 text-success" />
</div>
<h2 className="card-title text-center justify-center text-success">
{t('success.title')}
</h2>
<p className="text-center">{t('success.message')}</p>
</div>
</div>
);
}
if (error) {
return (
<div className="card bg-base-100 shadow-xl max-w-2xl mx-auto mt-8">
<div className="card-body">
<h2 className="card-title text-error">{t('error.title')}</h2>
<p>{error}</p>
</div>
</div>
);
}
if (!isVerified) {
return (
<div className="card bg-base-100 shadow-xl max-w-2xl mx-auto mt-8">
<div className="card-body">
<h2 className="card-title text-warning">{t('not-allowed.title')}</h2>
<p>{t('not-allowed.message')}</p>
</div>
</div>
);
}
return (
<div className="card bg-base-100 shadow-xl max-w-2xl mx-auto mt-8">
<div className="card-body">
<h2 className="card-title mb-3">{t('title')}</h2>
<div className="space-y-4">
<div>
<h3 className="font-semibold text-lg">{t('about.title')}</h3>
<p>{t('about.description')}</p>
</div>
<div>
<h3 className="font-semibold text-lg">{t('why.title')}</h3>
<p>{t('why.description')}</p>
</div>
<div>
<h3 className="font-semibold text-lg">{t('what-happens.title')}</h3>
<p>{t('what-happens.description')}</p>
</div>
</div>
<div className="card-actions justify-center mt-6">
<button
className="btn btn-error"
onClick={handleUnsubscribe}
disabled={isUnsubscribing}
>
{isUnsubscribing ? (
<>
<span className="loading loading-spinner"></span>
{t('button.unsubscribing')}
</>
) : (
t('button.unsubscribe')
)}
</button>
</div>
</div>
</div>
);
}

View File

@@ -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<BillingLocation>("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 (
<Main>
<Suspense fallback={<div className="text-center p-8">Loading...</div>}>
<EmailUnsubscribePage shareId={id} isVerified={isVerified} />
</Suspense>
</Main>
);
}

View File

@@ -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<string | null>(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 (
<div className="card bg-base-100 shadow-xl max-w-2xl mx-auto mt-8">
<div className="card-body">
<div className="flex justify-center mb-4">
<CheckCircleIcon className="h-16 w-16 text-success" />
</div>
<h2 className="card-title text-center justify-center text-success">
{t('success.title')}
</h2>
<p className="text-center">{t('success.message')}</p>
</div>
</div>
);
}
if (error) {
return (
<div className="card bg-base-100 shadow-xl max-w-2xl mx-auto mt-8">
<div className="card-body">
<h2 className="card-title text-error">{t('error.title')}</h2>
<p>{error}</p>
</div>
</div>
);
}
if (!isPending) {
return (
<div className="card bg-base-100 shadow-xl max-w-2xl mx-auto mt-8">
<div className="card-body">
<h2 className="card-title text-warning">{t('not-allowed.title')}</h2>
<p>{t('not-allowed.message')}</p>
</div>
</div>
);
}
return (
<div className="card bg-base-100 shadow-xl max-w-2xl mx-auto mt-8">
<div className="card-body">
<h2 className="card-title mb-3">{t('title')}</h2>
<div className="space-y-4">
<div>
<h3 className="font-semibold text-lg">{t('about.title')}</h3>
<p>{t('about.description')}</p>
</div>
<div>
<h3 className="font-semibold text-lg">{t('why.title')}</h3>
<p>{t('why.description')}</p>
</div>
<div>
<h3 className="font-semibold text-lg">{t('what-happens.title')}</h3>
<p>{t('what-happens.description')}</p>
</div>
<div>
<h3 className="font-semibold text-lg">{t('opt-out.title')}</h3>
<p>{t('opt-out.description')}</p>
</div>
</div>
<div className="card-actions justify-center mt-6">
<button
className="btn btn-primary"
onClick={handleVerify}
disabled={isVerifying}
>
{isVerifying ? (
<>
<span className="loading loading-spinner"></span>
{t('button.verifying')}
</>
) : (
t('button.verify')
)}
</button>
</div>
</div>
</div>
);
}

View File

@@ -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<BillingLocation>("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 (
<Main>
<Suspense fallback={<div className="text-center p-8">Loading...</div>}>
<EmailVerifyPage shareId={id} isPending={isPending} />
</Suspense>
</Main>
);
}

View File

@@ -9,6 +9,7 @@ import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
import { paragraphFormatFactory } from '../lib/paragraphFormatFactory'; import { paragraphFormatFactory } from '../lib/paragraphFormatFactory';
import { getAuthProviders } from '../lib/getProviders'; import { getAuthProviders } from '../lib/getProviders';
import { EnterOrSignInButton } from '../ui/EnterOrSignInButton'; 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 h1ClassName = "text-3xl font-bold max-w-[38rem] mx-auto text-neutral-50";
const h2ClassName = h1ClassName + " mt-8"; const h2ClassName = h1ClassName + " mt-8";
@@ -33,11 +34,10 @@ const Page: FC = async () => {
<h1 className={h1ClassName}> <h1 className={h1ClassName}>
{t.rich("main-card.title", paragraphFormat)} {t.rich("main-card.title", paragraphFormat)}
</h1> </h1>
{t.rich("main-card.text", paragraphFormat)}
<Image src={t("main-card.image-url")} alt={t("main-card.image-alt")} className="m-auto mt-0" width={400} height={300} />
<EnterOrSignInButton session={session} locale={locale} providers={providers} /> <EnterOrSignInButton session={session} locale={locale} providers={providers} />
{t.rich("main-card.text", paragraphFormat)}
<Image src={t("main-card.image-url")} alt={t("main-card.image-alt")} className="m-auto mt-0" width={400} height={300} />
{ {
// Google will refuse OAuth signin if it's inside a webview (i.e. Facebook) // Google will refuse OAuth signin if it's inside a webview (i.e. Facebook)
insideWebeview && insideWebeview &&

View File

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 32 KiB

View File

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

View File

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 5.3 KiB

View File

Before

Width:  |  Height:  |  Size: 7.7 KiB

After

Width:  |  Height:  |  Size: 7.7 KiB

View File

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

View File

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 52 KiB

View File

@@ -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<EmailActionResult> {
// 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<BillingLocation>("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<BillingLocation>("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<EmailActionResult> {
// 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<BillingLocation>("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<BillingLocation>("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)`
};
}

View File

@@ -2,7 +2,7 @@
import { z } from 'zod'; import { z } from 'zod';
import { getDbClient } from '../dbClient'; import { getDbClient } from '../dbClient';
import { BillingLocation, FileAttachment, YearMonth } from '../db-types'; import { BillingLocation, FileAttachment, YearMonth, EmailStatus } from '../db-types';
import { ObjectId } from 'mongodb'; import { ObjectId } from 'mongodb';
import { withUser } from '@/app/lib/auth'; import { withUser } from '@/app/lib/auth';
import { AuthenticatedUser } from '../types/next-auth'; import { AuthenticatedUser } from '../types/next-auth';
@@ -10,7 +10,7 @@ import { gotoHomeWithMessage } from './navigationActions';
import { unstable_noStore, revalidatePath } from 'next/cache'; import { unstable_noStore, revalidatePath } from 'next/cache';
import { IntlTemplateFn } from '@/app/i18n'; import { IntlTemplateFn } from '@/app/i18n';
import { getTranslations, getLocale } from "next-intl/server"; 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 { validatePdfFile } from '../validators/pdfValidator';
import { checkUploadRateLimit } from '../uploadRateLimiter'; import { checkUploadRateLimit } from '../uploadRateLimiter';
@@ -22,6 +22,7 @@ export type State = {
tenantTown?: string[]; tenantTown?: string[];
autoBillFwd?: string[]; autoBillFwd?: string[];
tenantEmail?: string[]; tenantEmail?: string[];
tenantEmailStatus?: string[];
billFwdStrategy?: string[]; billFwdStrategy?: string[];
rentDueNotification?: string[]; rentDueNotification?: string[];
rentDueDay?: string[]; rentDueDay?: string[];
@@ -44,6 +45,7 @@ const FormSchema = (t:IntlTemplateFn) => z.object({
tenantTown: z.string().max(27).optional().nullable(), tenantTown: z.string().max(27).optional().nullable(),
autoBillFwd: z.boolean().optional().nullable(), autoBillFwd: z.boolean().optional().nullable(),
tenantEmail: z.string().email(t("tenant-email-invalid")).optional().or(z.literal("")).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(), billFwdStrategy: z.enum(["when-payed", "when-attached"]).optional().nullable(),
rentDueNotification: z.boolean().optional().nullable(), rentDueNotification: z.boolean().optional().nullable(),
rentDueDay: z.coerce.number().min(1).max(31).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, tenantTown: formData.get('tenantTown') || null,
autoBillFwd: formData.get('autoBillFwd') === 'on', autoBillFwd: formData.get('autoBillFwd') === 'on',
tenantEmail: formData.get('tenantEmail') || null, 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, billFwdStrategy: formData.get('billFwdStrategy') as "when-payed" | "when-attached" | undefined,
rentDueNotification: formData.get('rentDueNotification') === 'on', rentDueNotification: formData.get('rentDueNotification') === 'on',
rentDueDay: formData.get('rentDueDay') || null, rentDueDay: formData.get('rentDueDay') || null,
@@ -147,6 +150,7 @@ export const updateOrAddLocation = withUser(async (user:AuthenticatedUser, locat
tenantTown, tenantTown,
autoBillFwd, autoBillFwd,
tenantEmail, tenantEmail,
tenantEmailStatus,
billFwdStrategy, billFwdStrategy,
rentDueNotification, rentDueNotification,
rentDueDay, 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 // Handle different update scopes
if (updateScope === "current" || !updateScope) { if (updateScope === "current" || !updateScope) {
// Update only the current location (default behavior) // Update only the current location (default behavior)
@@ -190,6 +208,7 @@ export const updateOrAddLocation = withUser(async (user:AuthenticatedUser, locat
tenantTown: tenantTown || null, tenantTown: tenantTown || null,
autoBillFwd: autoBillFwd || false, autoBillFwd: autoBillFwd || false,
tenantEmail: tenantEmail || null, tenantEmail: tenantEmail || null,
tenantEmailStatus: finalEmailStatus,
billFwdStrategy: billFwdStrategy || "when-payed", billFwdStrategy: billFwdStrategy || "when-payed",
rentDueNotification: rentDueNotification || false, rentDueNotification: rentDueNotification || false,
rentDueDay: rentDueDay || null, rentDueDay: rentDueDay || null,
@@ -221,6 +240,7 @@ export const updateOrAddLocation = withUser(async (user:AuthenticatedUser, locat
tenantTown: tenantTown || null, tenantTown: tenantTown || null,
autoBillFwd: autoBillFwd || false, autoBillFwd: autoBillFwd || false,
tenantEmail: tenantEmail || null, tenantEmail: tenantEmail || null,
tenantEmailStatus: finalEmailStatus,
billFwdStrategy: billFwdStrategy || "when-payed", billFwdStrategy: billFwdStrategy || "when-payed",
rentDueNotification: rentDueNotification || false, rentDueNotification: rentDueNotification || false,
rentDueDay: rentDueDay || null, rentDueDay: rentDueDay || null,
@@ -245,6 +265,7 @@ export const updateOrAddLocation = withUser(async (user:AuthenticatedUser, locat
tenantTown: tenantTown || null, tenantTown: tenantTown || null,
autoBillFwd: autoBillFwd || false, autoBillFwd: autoBillFwd || false,
tenantEmail: tenantEmail || null, tenantEmail: tenantEmail || null,
tenantEmailStatus: finalEmailStatus,
billFwdStrategy: billFwdStrategy || "when-payed", billFwdStrategy: billFwdStrategy || "when-payed",
rentDueNotification: rentDueNotification || false, rentDueNotification: rentDueNotification || false,
rentDueDay: rentDueDay || null, rentDueDay: rentDueDay || null,
@@ -268,6 +289,7 @@ export const updateOrAddLocation = withUser(async (user:AuthenticatedUser, locat
tenantTown: tenantTown || null, tenantTown: tenantTown || null,
autoBillFwd: autoBillFwd || false, autoBillFwd: autoBillFwd || false,
tenantEmail: tenantEmail || null, tenantEmail: tenantEmail || null,
tenantEmailStatus: tenantEmailStatus as EmailStatus || EmailStatus.Unverified,
billFwdStrategy: billFwdStrategy || "when-payed", billFwdStrategy: billFwdStrategy || "when-payed",
rentDueNotification: rentDueNotification || false, rentDueNotification: rentDueNotification || false,
rentDueDay: rentDueDay || null, rentDueDay: rentDueDay || null,
@@ -343,6 +365,7 @@ export const updateOrAddLocation = withUser(async (user:AuthenticatedUser, locat
tenantTown: tenantTown || null, tenantTown: tenantTown || null,
autoBillFwd: autoBillFwd || false, autoBillFwd: autoBillFwd || false,
tenantEmail: tenantEmail || null, tenantEmail: tenantEmail || null,
tenantEmailStatus: tenantEmailStatus as EmailStatus || EmailStatus.Unverified,
billFwdStrategy: billFwdStrategy || "when-payed", billFwdStrategy: billFwdStrategy || "when-payed",
rentDueNotification: rentDueNotification || false, rentDueNotification: rentDueNotification || false,
rentDueDay: rentDueDay || null, rentDueDay: rentDueDay || null,
@@ -446,6 +469,8 @@ export const fetchAllLocations = withUser(async (user:AuthenticatedUser, year:nu
"bills.payedAmount": 1, "bills.payedAmount": 1,
"bills.proofOfPayment.uploadedAt": 1, "bills.proofOfPayment.uploadedAt": 1,
"seenByTenantAt": 1, "seenByTenantAt": 1,
"tenantEmail": 1,
"tenantEmailStatus": 1,
// "bills.attachment": 0, // "bills.attachment": 0,
// "bills.notes": 0, // "bills.notes": 0,
// "bills.hub3aText": 1, // "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<string> {
const checksum = generateShareChecksum(locationId);
return locationId + checksum;
}
/** /**
* Generate/activate share link for location * Generate/activate share link for location
* Called when owner clicks "Share" button * Called when owner clicks "Share" button
@@ -775,7 +812,7 @@ export const generateShareLink = withUser(
); );
// Generate combined share ID (locationId + checksum) // Generate combined share ID (locationId + checksum)
const shareId = generateShareId(locationId); const shareId = await generateShareId(locationId);
// Build share URL // Build share URL
const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000'; const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000';

View File

@@ -1,3 +1,4 @@
import { unsubscribe } from "diagnostics_channel";
export interface FileAttachment { export interface FileAttachment {
fileName: string; fileName: string;
@@ -35,6 +36,17 @@ export interface UserSettings {
ownerRevolutProfileName?: string | null; 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 */ /** bill object in the form returned by MongoDB */
export interface BillingLocation { export interface BillingLocation {
_id: string; _id: string;
@@ -67,6 +79,8 @@ export interface BillingLocation {
autoBillFwd?: boolean | null; autoBillFwd?: boolean | null;
/** (optional) tenant email */ /** (optional) tenant email */
tenantEmail?: string | null; tenantEmail?: string | null;
/** (optional) tenant email status */
tenantEmailStatus?: EmailStatus | null;
/** (optional) bill forwarding strategy */ /** (optional) bill forwarding strategy */
billFwdStrategy?: "when-payed" | "when-attached" | null; billFwdStrategy?: "when-payed" | "when-attached" | null;
/** (optional) whether to automatically send rent notification */ /** (optional) whether to automatically send rent notification */

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