diff --git a/.github/workflows/backend-test.yml b/.github/workflows/backend-test.yml index 1c4121e1..2de6fa85 100644 --- a/.github/workflows/backend-test.yml +++ b/.github/workflows/backend-test.yml @@ -1,53 +1,109 @@ name: Backend Tests on: + workflow_dispatch: pull_request: branches: - main paths: - 'apps/backend/**' + - 'packages/shared/**' + - '.github/workflows/backend-test.yml' jobs: test-backend: runs-on: ubuntu-latest + + services: + mysql: + image: mysql:8 + env: + MYSQL_ROOT_PASSWORD: root + MYSQL_DATABASE: qrcodly_test + ports: + - 3306:3306 + options: >- + --health-cmd="mysqladmin ping -h 127.0.0.1" + --health-interval=10s + --health-timeout=5s + --health-retries=5 + + redis: + image: redis:7 + ports: + - 6379:6379 + options: >- + --health-cmd="redis-cli ping" + --health-interval=10s + --health-timeout=5s + --health-retries=5 + env: NODE_ENV: 'test' LOG_LEVEL: 'error' TZ: 'Europe/Berlin' + + # Database — service container + DB_HOST: '127.0.0.1' + DB_USER: 'root' + DB_PASSWORD: 'root' + DB_NAME: 'qrcodly_test' + TEST_DB_NAME: 'qrcodly_test' + DB_PORT: '3306' + + # Redis — service container + REDIS_URL: 'redis://127.0.0.1:6379' + + # S3 — MinIO service container + S3_ENDPOINT: 'http://127.0.0.1:9000' + S3_REGION: 'us-east-1' + S3_UPLOAD_KEY: 'minio' + S3_UPLOAD_SECRET: 'testtest1' + S3_BUCKET_NAME: 'qrcodly' + + # App config + BACKEND_URL: 'http://localhost:5001' + API_HOST: '127.0.0.1' + FRONTEND_URL: 'https://www.qrcodly.de' COOKIE_SECRET: ${{ secrets.COOKIE_SECRET }} JWT_SECRET: ${{ secrets.JWT_SECRET }} - REDIS_URL: ${{ secrets.REDIS_URL }} - API_HOST: ${{ secrets.API_HOST }} - FRONTEND_URL: ${{ secrets.FRONTEND_URL }} - DB_HOST: ${{ secrets.DB_HOST }} - DB_USER: ${{ secrets.DB_USER }} - DB_PASSWORD: ${{ secrets.DB_PASSWORD }} - DB_NAME: ${{ secrets.DB_NAME }} - TEST_DB_NAME: ${{ secrets.DB_NAME }} - DB_PORT: ${{ secrets.DB_PORT }} - SMTP_HOST: ${{ secrets.SMTP_HOST }} - SMTP_PORT: ${{ secrets.SMTP_PORT }} - SMTP_USER: ${{ secrets.SMTP_USER }} - SMTP_PASS: ${{ secrets.SMTP_PASS }} - S3_ENDPOINT: ${{ secrets.S3_ENDPOINT }} - S3_REGION: ${{ secrets.S3_REGION }} - S3_UPLOAD_KEY: ${{ secrets.S3_UPLOAD_KEY }} - S3_UPLOAD_SECRET: ${{ secrets.S3_UPLOAD_SECRET }} - S3_BUCKET_NAME: ${{ secrets.S3_BUCKET_NAME }} - SENTRY_DSN: ${{ secrets.SENTRY_DSN }} - SENTRY_ENVIRONMENT: 'development' + INTERNAL_API_SECRET: ${{ secrets.INTERNAL_API_SECRET }} + + # Clerk — real API needed for test JWT tokens CLERK_PUBLISHABLE_KEY: ${{ secrets.CLERK_PUBLISHABLE_KEY }} CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }} - UMAMI_HOST: ${{ secrets.UMAMI_HOST }} - UMAMI_WEBSITE: ${{ secrets.UMAMI_WEBSITE }} - UMAMI_USERNAME: ${{ secrets.UMAMI_USERNAME }} - UMAMI_PASSWORD: ${{ secrets.UMAMI_PASSWORD }} - BACKEND_URL: 'http://localhost:5001' CLERK_WEBHOOK_SECRET_KEY: 'NOT_USED_IN_TESTS' + + # Stripe — real test API needed for billing tests + STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }} + STRIPE_WEBHOOK_SECRET: ${{ secrets.STRIPE_WEBHOOK_SECRET }} + STRIPE_PRO_PRICE_ID_MONTHLY: ${{ secrets.STRIPE_PRO_PRICE_ID_MONTHLY }} + STRIPE_PRO_PRICE_ID_ANNUAL: ${{ secrets.STRIPE_PRO_PRICE_ID_ANNUAL }} + + # SMTP — not called in tests, dummy values + SMTP_HOST: 'localhost' + SMTP_PORT: '587' + SMTP_USER: 'test' + SMTP_PASS: 'test' + + # Sentry — not called in tests, dummy DSN + SENTRY_DSN: ${{ secrets.SENTRY_DSN }} + SENTRY_ENVIRONMENT: 'development' + + # Umami — mocked in tests, dummy values + UMAMI_HOST: 'http://localhost:3001' + UMAMI_WEBSITE: 'test-website-id' + UMAMI_USERNAME: 'test' + UMAMI_PASSWORD: 'test' + + # Cloudflare — not called in tests CLOUDFLARE_API_TOKEN: 'NOT_USED_IN_TESTS' CLOUDFLARE_ZONE_ID: 'NOT_USED_IN_TESTS' CLOUDFLARE_DCV_DELEGATION_TARGET: 'NOT_USED_IN_TESTS' + # Analytics encryption + ANALYTICS_ENCRYPTION_KEY: ${{ secrets.ANALYTICS_ENCRYPTION_KEY }} + steps: - uses: actions/checkout@v5 - uses: pnpm/action-setup@v4 @@ -61,6 +117,24 @@ jobs: - name: Build shared package run: pnpm --filter @shared/schemas build + - name: Start MinIO and create bucket + run: | + docker run -d --name minio \ + -p 9000:9000 \ + -e MINIO_ROOT_USER=minio \ + -e MINIO_ROOT_PASSWORD=testtest1 \ + minio/minio:latest server /data + # Wait for MinIO to be ready + for i in $(seq 1 30); do + curl -sf http://127.0.0.1:9000/minio/health/live && break + sleep 1 + done + # Create bucket + curl -sL https://dl.min.io/client/mc/release/linux-amd64/mc -o /usr/local/bin/mc + chmod +x /usr/local/bin/mc + mc alias set ci http://127.0.0.1:9000 minio testtest1 + mc mb ci/qrcodly --ignore-existing + - name: Run Checks working-directory: apps/backend run: pnpm run pr:precheck diff --git a/.gitignore b/.gitignore index d87d8503..b2c61feb 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,9 @@ yarn.lock .env .vscode/* !.vscode/settings.json +.claude +.claude-flow +.swarm .idea **/package-lock.json packages/**/.turbo diff --git a/CLAUDE.md b/CLAUDE.md index 3838a66e..4d8b98d7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,12 +10,12 @@ QRcodly is a full-stack QR code generator and management platform. It's a pnpm m ``` apps/ -├── backend/ # Fastify REST API (Node.js) -├── frontend/ # Next.js web application -└── browser-extension/ # Vite-based browser extension +├── backend/ # Fastify REST API (Node.js) +├── frontend/ # Next.js 16 web application (React 19) +└── browser-extension/ # Vite-based browser extension packages/ -├── shared/ # @shared/schemas - DTOs, Zod schemas, utilities shared between frontend/backend +├── shared/ # @shared/schemas - DTOs, Zod v4 schemas, utilities ├── eslint-config/ └── typescript-config/ ``` @@ -39,10 +39,10 @@ pnpm run frontend:dev # Next.js at :3000 (uses turbo) ### Building ```bash -pnpm run build # Build all apps via Turbo -pnpm run build:shared-package # Build shared package only -pnpm run backend:build # Build backend (builds shared first) -pnpm run frontend:build # Build frontend (builds shared first) +pnpm run build # Build all apps via Turbo +pnpm run build:shared-package # Build shared package only (must run before app builds) +pnpm run backend:build # Build backend (builds shared first) +pnpm run frontend:build # Build frontend (builds shared first) ``` ### Testing @@ -82,64 +82,79 @@ pnpm run studio # Open Drizzle Studio ### Backend (`apps/backend`) -**Tech Stack**: Fastify, Drizzle ORM (MySQL), Redis, Clerk auth, S3/MinIO storage, Pino logging - -**Structure**: - -- `src/core/` - Framework and infrastructure - - `config/` - Environment variables and constants - - `db/` - Database connection, schema, migrations - - `cache/` - Redis caching layer - - `storage/` - S3/MinIO file uploads - - `mailer/` - Nodemailer with Handlebars templates (`templates/`) - - `error/` - Custom error classes - - `event/` - Event system for async operations - - `rate-limit/` - Rate limiting configuration - - `policies/` - Authorization policies - - `server.ts` - Fastify server setup -- `src/modules/` - Feature modules (each has http/, service/, and often `__tests__/`) - - `qr-code/` - QR code generation and management (supports URL, vCard, WiFi, Email, Event, Location, Text types) - - `custom-domain/` - Custom domain management with Cloudflare integration - - `url-shortener/` - URL shortening and analytics tracking - - `config-template/` - User-defined QR code templates - - `subscription/` - Billing and subscription management - -**Dependency Injection**: Uses `tsyringe` for DI container +**Tech Stack**: Fastify, Drizzle ORM (MySQL), Redis, Clerk auth, S3/MinIO, Pino logging, tsyringe DI + +**Module Structure** (`src/modules/`): 7 modules — `qr-code`, `url-shortener`, `billing`, `custom-domain`, `config-template`, `tag`, `analytics-integration` + +Each module follows this layout: + +- `domain/entities/` — Drizzle table definitions + TypeScript types +- `domain/repository/` — Data access extending `AbstractRepository` +- `useCase/` — Business logic classes (`*.use-case.ts`) +- `http/controller/` — Decorator-based route handlers +- `http/__tests__/` — Integration tests +- `service/` — Stateless services (optional) +- `event/handler/` — Event handlers (optional) +- `setup.ts` — Module registration via `registerRoutes()` + +**DI Conventions (tsyringe)**: + +- Controllers and UseCases: `@injectable()` (transient, new instance per resolution) +- Repositories and Services: `@singleton()` (single shared instance) +- All deps injected via `@inject(ClassName)` constructor params + +**Route Registration**: Decorator-based (`@Get`, `@Post`, `@Put`, `@Patch`, `@Delete`) with Zod schema validation for body/query/response. Routes are registered in each module's `setup.ts` via `registerRoutes(fastify, ControllerClass, prefix)`. + +**API prefix**: `/api/v1` (e.g., `/api/v1/qr-code`, `/api/v1/short-url`, `/api/v1/billing`) + +**Core infrastructure** (`src/core/`): + +- `db/` — Connection, schema re-exports, migrations. All entity schemas centrally re-exported from `db/schemas/index.ts` +- `cache/` — Redis caching (`KeyCache`) +- `storage/` — S3/MinIO file uploads +- `server.ts` — Fastify server setup, plugin registration, module loading + +**Database patterns**: `AbstractRepository` provides pagination, caching, soft deletes. `UnitOfWork` + `TransactionContext` for multi-operation transactions. ### Frontend (`apps/frontend`) -**Tech Stack**: Next.js (App Router), React, Tailwind CSS, shadcn/ui, Zustand, TanStack Query, Clerk, next-intl (i18n) +**Tech Stack**: Next.js 16 (App Router, Turbo dev), React 19, Tailwind CSS, shadcn/ui, Zustand, TanStack Query, Clerk, next-intl + +**Routing**: `src/app/[locale]/` — locale-prefixed routes. Dashboard routes under `/dashboard/` are protected via Clerk middleware. + +**Middleware**: `src/proxy.ts` chains Clerk auth + next-intl middleware. Handles `/u/[shortCode]` analytics redirects. -**Structure**: +**API calls** (`src/lib/api/`): `apiRequest()` utility with Bearer token auth from Clerk. Each API module exports TanStack Query hooks (e.g., `useListQrCodesQuery`, `useCreateQrCodeMutation`). -- `src/app/[locale]/` - Locale-prefixed routes (i18n) -- `src/components/` - React components - - `ui/` - shadcn/ui primitives - - `dashboard/` - Dashboard feature sections - - `qr-generator/` - QR code creation UI -- `src/store/` - Zustand state management -- `src/dictionaries/` - Translation files (en, de, es, fr, it, nl, pl, ru) -- `src/lib/api/` - API client utilities +**State**: Zustand store (`src/store/useQrCodeStore.ts`) for QR code generator form state with localStorage persistence. + +**Env validation**: `@t3-oss/env-nextjs` in `src/env.js` — type-safe server/client env vars. + +**i18n**: 8 locales (en, de, es, fr, it, nl, pl, ru). Translation files in `src/dictionaries/`. Uses `localePrefix: 'as-needed'`. ### Shared Package (`packages/shared`) -Published as `@shared/schemas`. Contains: +Published as `@shared/schemas`. Import via: `import { ... } from '@shared/schemas'` + +Uses **Zod v4** (from pnpm catalog). Contains: + +- `src/schemas/` — Source-of-truth Zod schemas (e.g., `QrCode.ts` with discriminated union for 8 content types: URL, Text, WiFi, vCard, Email, Location, Event, EPC) +- `src/dtos/` — Request/response DTOs derived from schemas via `.pick()`, `.extend()`. Organized by feature domain. +- `src/utils/` — QR code content converters, default styling options, deep diff utility -- Zod validation schemas -- DTOs for API request/response types -- Shared utilities and QR code defaults +**DTO naming**: `CreateXDto`, `UpdateXDto`, `XResponseDto`, `XPaginatedResponseDto`, `XListRequestDto` -Import via: `import { ... } from '@shared/schemas'` +Build: `tsc` → `dist/` (CommonJS). Must be built before backend/frontend (`pnpm run build:shared-package`). ## Local Development Environment Docker Compose provides: -- MySQL (port 3306) - credentials: root/root, database: qrcodly -- Redis (port 6379) -- MinIO S3 mock (ports 9000 API, 9001 console) - credentials: minio/testtest -- phpMyAdmin (port 8081) -- Umami analytics (port 3001) +- MySQL (3306) — root/root, database: qrcodly +- Redis (6379) +- MinIO S3 mock (9000 API, 9001 console) — minio/testtest +- phpMyAdmin (8081) +- Umami analytics (3001) ## Testing Patterns @@ -148,11 +163,12 @@ Backend tests use Jest with: - Global setup/teardown for test database isolation - `--runInBand` for sequential execution - 30 second timeout per test -- Tests located in `__tests__/` directories within modules +- Tests in `__tests__/` directories within module `http/` dirs ## Key Conventions - **Database naming**: snake_case for tables and columns -- **TypeScript**: Strict mode, path aliases (`@/*` for local, `@shared/schemas/*` for shared) +- **TypeScript**: Strict mode, path aliases (`@/*` for local, `@shared/schemas` or `@shared/schemas/*` for shared) - **Formatting**: Prettier with tabs, semicolons, 100 char width -- **Git hooks**: lefthook runs Prettier on pre-commit +- **Git hooks**: lefthook runs Prettier on staged files at pre-commit +- **Locale translations**: When adding/changing user-facing strings, update all 8 locale dictionaries diff --git a/apps/backend/package.json b/apps/backend/package.json index 3b4cfe59..68d47d89 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -20,8 +20,8 @@ "start": "pnpm run db:migrate && TZ=Europe/Berlin node build/src/index.js", "dev-server": "docker-compose up -d", "dev": "pnpm run db:migrate && TZ=Europe/Berlin tsx watch --include ./src src/index.ts", - "test": "NODE_ENV=test LOG_LEVEL=error pnpm run db:migrate && jest --runInBand", - "test:coverage": "NODE_ENV=test LOG_LEVEL=error pnpm run db:migrate && jest --runInBand --coverage", + "test": "export NODE_ENV=test && export LOG_LEVEL=error && export FRONTEND_URL=https://test.qrcodly.de && pnpm run db:migrate && jest --runInBand", + "test:coverage": "export NODE_ENV=test && export LOG_LEVEL=error && export FRONTEND_URL=https://test.qrcodly.de && pnpm run db:migrate && jest --runInBand --coverage", "test:detectOpenHandles": "jest --silent --runInBand --detectOpenHandles", "db:push": "drizzle-kit push --config=drizzle.config.ts", "db:migrate": "DB_MIGRATING=true tsx src/core/db/migrate.ts", @@ -29,7 +29,8 @@ "studio": "drizzle-kit studio", "cli:list": "npx tsx src/cli.ts list", "script:find-orphaned-urls": "SKIP_ENV_VALIDATION=true tsx scripts/find-orphaned-short-urls.ts", - "script:test-smtp": "tsx scripts/test-smtp.ts" + "script:test-smtp": "tsx scripts/test-smtp.ts", + "script:regenerate-previews": "SKIP_ENV_VALIDATION=true tsx scripts/regenerate-preview-images.ts" }, "devDependencies": { "@aws-sdk/types": "^3.936.0", @@ -61,7 +62,7 @@ "dependencies": { "@aws-sdk/client-s3": "^3.943.0", "@aws-sdk/s3-request-presigner": "^3.943.0", - "@axiomhq/pino": "^1.3.1", + "@axiomhq/pino": "^1.4.0", "@clerk/fastify": "^2.6.17", "@fastify/cookie": "^11.0.2", "@fastify/cors": "^11.1.0", diff --git a/apps/backend/scripts/regenerate-preview-images.ts b/apps/backend/scripts/regenerate-preview-images.ts new file mode 100644 index 00000000..a1cf6be0 --- /dev/null +++ b/apps/backend/scripts/regenerate-preview-images.ts @@ -0,0 +1,272 @@ +//@ts-nocheck +import 'dotenv/config'; + +import { z } from 'zod'; +import { drizzle } from 'drizzle-orm/mysql2'; +import mysql from 'mysql2/promise'; +import { GetObjectOutput, S3 } from '@aws-sdk/client-s3'; +import { createTable } from '../src/core/db/utils'; +import { datetime, json, text, varchar } from 'drizzle-orm/mysql-core'; +import { eq, isNull } from 'drizzle-orm'; +import { convertQrCodeOptionsToLibraryOptions } from '@shared/schemas'; +import { generateQrCodeStylingInstance } from '../src/modules/qr-code/lib/styled-qr-code'; + +const CONCURRENCY = 20; + +const scriptEnv = z + .object({ + DB_HOST: z.string(), + DB_USER: z.string(), + DB_PASSWORD: z.string(), + DB_NAME: z.string(), + DB_PORT: z.string(), + S3_ENDPOINT: z.string(), + S3_REGION: z.string(), + S3_UPLOAD_KEY: z.string(), + S3_UPLOAD_SECRET: z.string(), + S3_BUCKET_NAME: z.string(), + }) + .parse(process.env); + +// Inline table definitions to avoid importing the full schema (which pulls in env validation) +const qrCode = createTable('qr_code', { + id: varchar('id', { length: 36 }).primaryKey(), + config: json().notNull(), + content: json().notNull(), + qrCodeData: text(), + previewImage: text(), + createdBy: varchar({ length: 255 }), + createdAt: datetime().notNull(), +}); + +const configTemplate = createTable('qr_code_config_template', { + id: varchar('id', { length: 36 }).primaryKey(), + config: json().notNull(), + previewImage: text(), + createdBy: varchar({ length: 255 }), + createdAt: datetime().notNull(), +}); + +const QR_CODE_PREVIEW_IMAGE_FOLDER = 'qr-codes/images/previews'; +const CONFIG_TEMPLATE_PREVIEW_IMAGE_FOLDER = 'config-templates/images/previews'; + +const extensionToMimeType: Record = { + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + png: 'image/png', + svg: 'image/svg+xml', + webp: 'image/webp', +}; + +async function getImageAsDataUrl(s3: S3, storagePath: string): Promise { + try { + const response: GetObjectOutput = await s3.getObject({ + Bucket: scriptEnv.S3_BUCKET_NAME, + Key: storagePath, + }); + if (!response.Body) return undefined; + + const bytes = await response.Body.transformToByteArray(); + const buffer = Buffer.from(bytes); + const ext = storagePath.split('.').pop()?.toLowerCase() ?? ''; + const mimeType = extensionToMimeType[ext] ?? 'application/octet-stream'; + return `data:${mimeType};base64,${buffer.toString('base64')}`; + } catch { + return undefined; + } +} + +function constructFilePath(folder: string, userId: string | undefined, fileName: string): string { + return userId ? `${folder}/${userId}/${fileName}` : `${folder}/${fileName}`; +} + +async function processInParallel( + items: T[], + concurrency: number, + label: string, + fn: (item: T, index: number) => Promise, +): Promise<{ success: number; fail: number }> { + let success = 0; + let fail = 0; + let nextIndex = 0; + const total = items.length; + + async function worker() { + while (nextIndex < total) { + const i = nextIndex++; + try { + const ok = await fn(items[i], i); + if (ok) success++; + else fail++; + } catch { + fail++; + } + if ((success + fail) % 500 === 0) { + console.log( + ` ${label}: ${success + fail}/${total} processed (${success} ok, ${fail} failed)`, + ); + } + } + } + + const workers = Array.from({ length: Math.min(concurrency, total) }, () => worker()); + await Promise.all(workers); + + return { success, fail }; +} + +async function main() { + console.log('=== Regenerate Preview Images ===\n'); + + const poolConnection = mysql.createPool({ + host: scriptEnv.DB_HOST, + user: scriptEnv.DB_USER, + password: scriptEnv.DB_PASSWORD, + database: scriptEnv.DB_NAME, + port: Number(scriptEnv.DB_PORT), + connectionLimit: CONCURRENCY, + }); + + const db = drizzle(poolConnection, { mode: 'default', casing: 'snake_case' }); + + const s3 = new S3({ + endpoint: scriptEnv.S3_ENDPOINT, + region: scriptEnv.S3_REGION, + credentials: { + accessKeyId: scriptEnv.S3_UPLOAD_KEY, + secretAccessKey: scriptEnv.S3_UPLOAD_SECRET, + }, + forcePathStyle: true, + }); + + // 1. Fetch QR codes with missing preview images + console.log('Fetching QR codes with missing preview images...'); + const qrCodes = await db + .select({ + id: qrCode.id, + createdBy: qrCode.createdBy, + config: qrCode.config, + qrCodeData: qrCode.qrCodeData, + }) + .from(qrCode) + .where(isNull(qrCode.previewImage)); + + console.log(`Found ${qrCodes.length} QR codes to process`); + + // 2. Fetch config templates with missing preview images + console.log('Fetching config templates with missing preview images...'); + const templates = await db + .select({ + id: configTemplate.id, + createdBy: configTemplate.createdBy, + config: configTemplate.config, + }) + .from(configTemplate) + .where(isNull(configTemplate.previewImage)); + + console.log(`Found ${templates.length} config templates to process\n`); + + // 3. Process QR codes in parallel + console.log(`--- Processing QR codes (concurrency: ${CONCURRENCY}) ---`); + const qrResult = await processInParallel(qrCodes, CONCURRENCY, 'QR codes', async (row) => { + if (!row.qrCodeData) return false; + + const libraryOptions = convertQrCodeOptionsToLibraryOptions(row.config as any); + + if (libraryOptions.image) { + libraryOptions.image = (await getImageAsDataUrl(s3, libraryOptions.image)) ?? undefined; + } + + const instance = generateQrCodeStylingInstance({ + ...libraryOptions, + data: row.qrCodeData, + }); + + const svg = await instance.getRawData('svg'); + if (!svg) return false; + + const buffer = Buffer.isBuffer(svg) ? svg : Buffer.from(await svg.arrayBuffer()); + const fileName = `${row.id}.svg`; + const filePath = constructFilePath( + QR_CODE_PREVIEW_IMAGE_FOLDER, + row.createdBy ?? undefined, + fileName, + ); + + await s3.putObject({ + Bucket: scriptEnv.S3_BUCKET_NAME, + Key: filePath, + Body: buffer, + ContentType: 'image/svg+xml', + }); + + await db.update(qrCode).set({ previewImage: filePath }).where(eq(qrCode.id, row.id)); + + return true; + }); + + // 4. Process config templates in parallel + console.log(`\n--- Processing config templates (concurrency: ${CONCURRENCY}) ---`); + const templateResult = await processInParallel( + templates, + CONCURRENCY, + 'Templates', + async (row) => { + const libraryOptions = convertQrCodeOptionsToLibraryOptions(row.config as any); + + if (libraryOptions.image) { + libraryOptions.image = (await getImageAsDataUrl(s3, libraryOptions.image)) ?? undefined; + } + + const instance = generateQrCodeStylingInstance({ + ...libraryOptions, + data: 'https://www.qrcodly.de/', + }); + + const svg = await instance.getRawData('svg'); + if (!svg) return false; + + const buffer = Buffer.isBuffer(svg) ? svg : Buffer.from(await svg.arrayBuffer()); + const fileName = `${row.id}.svg`; + const filePath = constructFilePath( + CONFIG_TEMPLATE_PREVIEW_IMAGE_FOLDER, + row.createdBy ?? undefined, + fileName, + ); + + await s3.putObject({ + Bucket: scriptEnv.S3_BUCKET_NAME, + Key: filePath, + Body: buffer, + ContentType: 'image/svg+xml', + }); + + await db + .update(configTemplate) + .set({ previewImage: filePath }) + .where(eq(configTemplate.id, row.id)); + + return true; + }, + ); + + // 5. Summary + console.log('\n' + '='.repeat(50)); + console.log('SUMMARY'); + console.log('='.repeat(50)); + console.log( + `QR code previews: ${qrResult.success} success, ${qrResult.fail} failed (of ${qrCodes.length})`, + ); + console.log( + `Template previews: ${templateResult.success} success, ${templateResult.fail} failed (of ${templates.length})`, + ); + console.log('='.repeat(50)); + + await poolConnection.end(); + console.log('\nDone.'); +} + +main().catch((err) => { + console.error('Script failed:', err); + process.exit(1); +}); diff --git a/apps/backend/src/application.ts b/apps/backend/src/application.ts index 775f1bb3..81366504 100644 --- a/apps/backend/src/application.ts +++ b/apps/backend/src/application.ts @@ -1,6 +1,7 @@ import './core/setup'; import { container } from 'tsyringe'; import { Logger } from './core/logging'; +import { ErrorReporter } from './core/error'; import { ShutdownService } from './core/services/shutdown.service'; import { Server } from './core/server'; import { poolConnection } from './core/db'; @@ -8,6 +9,7 @@ import { sleep } from './utils/general'; import { CronJobWorker } from './core/jobs/cron-job-worker'; export class Application { + private errorReporter = container.resolve(ErrorReporter); private logger = container.resolve(Logger); private shutdownService = container.resolve(ShutdownService); public server = container.resolve(Server); diff --git a/apps/backend/src/core/config/plan.config.ts b/apps/backend/src/core/config/plan.config.ts index 4f25355f..a64a0b4e 100644 --- a/apps/backend/src/core/config/plan.config.ts +++ b/apps/backend/src/core/config/plan.config.ts @@ -33,16 +33,6 @@ export type BulkImportLimits = { maxFileSizeBytes: number; }; -/** - * Plan limits for tags per QR code. - * - free: 1 tag per QR code - * - pro: 3 tags per QR code - */ -export const TAGS_PER_QR_CODE_PLAN_LIMITS: Record = { - free: 1, - pro: 3, -}; - /** * Plan limits for analytics integrations. * - free: 0 integrations (not available) diff --git a/apps/backend/src/core/db/migrations/0026_greedy_patriot.sql b/apps/backend/src/core/db/migrations/0026_greedy_patriot.sql new file mode 100644 index 00000000..3c1fe24a --- /dev/null +++ b/apps/backend/src/core/db/migrations/0026_greedy_patriot.sql @@ -0,0 +1,10 @@ +CREATE TABLE `short_url_tag` ( + `short_url_id` varchar(36) NOT NULL, + `tag_id` varchar(36) NOT NULL, + CONSTRAINT `short_url_tag_short_url_id_tag_id_pk` PRIMARY KEY(`short_url_id`,`tag_id`) +); +--> statement-breakpoint +ALTER TABLE `short_url` ADD `name` varchar(255);--> statement-breakpoint +ALTER TABLE `short_url_tag` ADD CONSTRAINT `short_url_tag_short_url_id_short_url_id_fk` FOREIGN KEY (`short_url_id`) REFERENCES `short_url`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `short_url_tag` ADD CONSTRAINT `short_url_tag_tag_id_tag_id_fk` FOREIGN KEY (`tag_id`) REFERENCES `tag`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX `i_short_url_tag_tag_id` ON `short_url_tag` (`tag_id`); \ No newline at end of file diff --git a/apps/backend/src/core/db/migrations/meta/0026_snapshot.json b/apps/backend/src/core/db/migrations/meta/0026_snapshot.json new file mode 100644 index 00000000..a103cd6e --- /dev/null +++ b/apps/backend/src/core/db/migrations/meta/0026_snapshot.json @@ -0,0 +1,1043 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "a3a5f7cd-ba32-4cf8-9eed-61bd048140bf", + "prevId": "3a913eb4-08ce-4e9c-853e-4aa51f22aa95", + "tables": { + "analytics_integration": { + "name": "analytics_integration", + "columns": { + "id": { + "name": "id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_type": { + "name": "provider_type", + "type": "enum('google_analytics','matomo')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "encrypted_credentials": { + "name": "encrypted_credentials", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "encryption_iv": { + "name": "encryption_iv", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "encryption_tag": { + "name": "encryption_tag", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_error_at": { + "name": "last_error_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "consecutive_failures": { + "name": "consecutive_failures", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_by": { + "name": "created_by", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "i_analytics_integration_created_by": { + "name": "i_analytics_integration_created_by", + "columns": ["created_by"], + "isUnique": false + }, + "i_analytics_integration_created_by_enabled": { + "name": "i_analytics_integration_created_by_enabled", + "columns": ["created_by", "is_enabled"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "analytics_integration_id": { + "name": "analytics_integration_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "qr_code_config_template": { + "name": "qr_code_config_template", + "columns": { + "id": { + "name": "id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "config": { + "name": "config", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "preview_image": { + "name": "preview_image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_predefined": { + "name": "is_predefined", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_by": { + "name": "created_by", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "i_config_template_created_by_created_at": { + "name": "i_config_template_created_by_created_at", + "columns": ["created_by", "created_at"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "qr_code_config_template_id": { + "name": "qr_code_config_template_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "custom_domain": { + "name": "custom_domain", + "columns": { + "id": { + "name": "id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "domain": { + "name": "domain", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_by": { + "name": "created_by", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "verification_phase": { + "name": "verification_phase", + "type": "enum('dns_verification','cloudflare_ssl')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'dns_verification'" + }, + "ownership_txt_verified": { + "name": "ownership_txt_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "cname_verified": { + "name": "cname_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "cloudflare_hostname_id": { + "name": "cloudflare_hostname_id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ssl_status": { + "name": "ssl_status", + "type": "enum('initializing','pending_validation','pending_issuance','pending_deployment','active','pending_expiration','expired','deleted','validation_timed_out')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'initializing'" + }, + "ownership_status": { + "name": "ownership_status", + "type": "enum('pending','verified')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "ssl_validation_txt_name": { + "name": "ssl_validation_txt_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ssl_validation_txt_value": { + "name": "ssl_validation_txt_value", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ownership_validation_txt_name": { + "name": "ownership_validation_txt_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ownership_validation_txt_value": { + "name": "ownership_validation_txt_value", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "validation_errors": { + "name": "validation_errors", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "i_custom_domain_created_by_created_at": { + "name": "i_custom_domain_created_by_created_at", + "columns": ["created_by", "created_at"], + "isUnique": false + }, + "i_custom_domain_domain": { + "name": "i_custom_domain_domain", + "columns": ["domain"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "custom_domain_id": { + "name": "custom_domain_id", + "columns": ["id"] + } + }, + "uniqueConstraints": { + "custom_domain_domain_unique": { + "name": "custom_domain_domain_unique", + "columns": ["domain"] + } + }, + "checkConstraint": {} + }, + "qr_code": { + "name": "qr_code", + "columns": { + "id": { + "name": "id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "config": { + "name": "config", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "qr_code_data": { + "name": "qr_code_data", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "preview_image": { + "name": "preview_image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_by": { + "name": "created_by", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "i_qr_code_created_by_created_at": { + "name": "i_qr_code_created_by_created_at", + "columns": ["created_by", "created_at"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "qr_code_id": { + "name": "qr_code_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "qr_code_share": { + "name": "qr_code_share", + "columns": { + "id": { + "name": "id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "qr_code_id": { + "name": "qr_code_id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "share_token": { + "name": "share_token", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "config": { + "name": "config", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_by": { + "name": "created_by", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "i_qr_code_share_qr_code_id": { + "name": "i_qr_code_share_qr_code_id", + "columns": ["qr_code_id"], + "isUnique": false + }, + "i_qr_code_share_token": { + "name": "i_qr_code_share_token", + "columns": ["share_token"], + "isUnique": false + }, + "i_qr_code_share_created_by": { + "name": "i_qr_code_share_created_by", + "columns": ["created_by"], + "isUnique": false + } + }, + "foreignKeys": { + "qr_code_share_qr_code_id_qr_code_id_fk": { + "name": "qr_code_share_qr_code_id_qr_code_id_fk", + "tableFrom": "qr_code_share", + "tableTo": "qr_code", + "columnsFrom": ["qr_code_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "qr_code_share_id": { + "name": "qr_code_share_id", + "columns": ["id"] + } + }, + "uniqueConstraints": { + "qr_code_share_qr_code_id_unique": { + "name": "qr_code_share_qr_code_id_unique", + "columns": ["qr_code_id"] + }, + "qr_code_share_share_token_unique": { + "name": "qr_code_share_share_token_unique", + "columns": ["share_token"] + } + }, + "checkConstraint": {} + }, + "qr_code_tag": { + "name": "qr_code_tag", + "columns": { + "qr_code_id": { + "name": "qr_code_id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tag_id": { + "name": "tag_id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "i_qr_code_tag_tag_id": { + "name": "i_qr_code_tag_tag_id", + "columns": ["tag_id"], + "isUnique": false + } + }, + "foreignKeys": { + "qr_code_tag_qr_code_id_qr_code_id_fk": { + "name": "qr_code_tag_qr_code_id_qr_code_id_fk", + "tableFrom": "qr_code_tag", + "tableTo": "qr_code", + "columnsFrom": ["qr_code_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "qr_code_tag_tag_id_tag_id_fk": { + "name": "qr_code_tag_tag_id_tag_id_fk", + "tableFrom": "qr_code_tag", + "tableTo": "tag", + "columnsFrom": ["tag_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "qr_code_tag_qr_code_id_tag_id_pk": { + "name": "qr_code_tag_qr_code_id_tag_id_pk", + "columns": ["qr_code_id", "tag_id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "short_url": { + "name": "short_url", + "columns": { + "id": { + "name": "id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "short_code": { + "name": "short_code", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "destination_url": { + "name": "destination_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "qr_code_id": { + "name": "qr_code_id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "custom_domain_id": { + "name": "custom_domain_id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_by": { + "name": "created_by", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "i_short_url_created_by_created_at": { + "name": "i_short_url_created_by_created_at", + "columns": ["created_by", "created_at"], + "isUnique": false + }, + "i_short_url_qr_code_id": { + "name": "i_short_url_qr_code_id", + "columns": ["qr_code_id"], + "isUnique": false + }, + "i_short_url_reserved": { + "name": "i_short_url_reserved", + "columns": ["created_by", "qr_code_id"], + "isUnique": false + }, + "i_short_url_custom_domain_id": { + "name": "i_short_url_custom_domain_id", + "columns": ["custom_domain_id"], + "isUnique": false + } + }, + "foreignKeys": { + "short_url_qr_code_id_qr_code_id_fk": { + "name": "short_url_qr_code_id_qr_code_id_fk", + "tableFrom": "short_url", + "tableTo": "qr_code", + "columnsFrom": ["qr_code_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "short_url_custom_domain_id_custom_domain_id_fk": { + "name": "short_url_custom_domain_id_custom_domain_id_fk", + "tableFrom": "short_url", + "tableTo": "custom_domain", + "columnsFrom": ["custom_domain_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "short_url_id": { + "name": "short_url_id", + "columns": ["id"] + } + }, + "uniqueConstraints": { + "short_url_shortCode_unique": { + "name": "short_url_shortCode_unique", + "columns": ["short_code"] + }, + "short_url_qrCodeId_unique": { + "name": "short_url_qrCodeId_unique", + "columns": ["qr_code_id"] + } + }, + "checkConstraint": {} + }, + "short_url_tag": { + "name": "short_url_tag", + "columns": { + "short_url_id": { + "name": "short_url_id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tag_id": { + "name": "tag_id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "i_short_url_tag_tag_id": { + "name": "i_short_url_tag_tag_id", + "columns": ["tag_id"], + "isUnique": false + } + }, + "foreignKeys": { + "short_url_tag_short_url_id_short_url_id_fk": { + "name": "short_url_tag_short_url_id_short_url_id_fk", + "tableFrom": "short_url_tag", + "tableTo": "short_url", + "columnsFrom": ["short_url_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "short_url_tag_tag_id_tag_id_fk": { + "name": "short_url_tag_tag_id_tag_id_fk", + "tableFrom": "short_url_tag", + "tableTo": "tag", + "columnsFrom": ["tag_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "short_url_tag_short_url_id_tag_id_pk": { + "name": "short_url_tag_short_url_id_tag_id_pk", + "columns": ["short_url_id", "tag_id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "tag": { + "name": "tag", + "columns": { + "id": { + "name": "id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "varchar(7)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_by": { + "name": "created_by", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "i_tag_created_by_created_at": { + "name": "i_tag_created_by_created_at", + "columns": ["created_by", "created_at"], + "isUnique": false + }, + "i_tag_created_by_name": { + "name": "i_tag_created_by_name", + "columns": ["created_by", "name"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "tag_id": { + "name": "tag_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "user_subscription": { + "name": "user_subscription", + "columns": { + "id": { + "name": "id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "stripe_price_id": { + "name": "stripe_price_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "current_period_start": { + "name": "current_period_start", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "current_period_end": { + "name": "current_period_end", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "grace_period_ends_at": { + "name": "grace_period_ends_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pro_features_disabled_at": { + "name": "pro_features_disabled_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cancellation_notified_at": { + "name": "cancellation_notified_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cancellation_reminder_sent_at": { + "name": "cancellation_reminder_sent_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "past_due_notified_at": { + "name": "past_due_notified_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "i_user_subscription_user_id": { + "name": "i_user_subscription_user_id", + "columns": ["user_id"], + "isUnique": false + }, + "i_user_subscription_stripe_customer_id": { + "name": "i_user_subscription_stripe_customer_id", + "columns": ["stripe_customer_id"], + "isUnique": false + }, + "i_user_subscription_stripe_subscription_id": { + "name": "i_user_subscription_stripe_subscription_id", + "columns": ["stripe_subscription_id"], + "isUnique": false + }, + "i_user_subscription_status": { + "name": "i_user_subscription_status", + "columns": ["status"], + "isUnique": false + }, + "i_user_subscription_grace_period_ends_at": { + "name": "i_user_subscription_grace_period_ends_at", + "columns": ["grace_period_ends_at"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "user_subscription_id": { + "name": "user_subscription_id", + "columns": ["id"] + } + }, + "uniqueConstraints": { + "user_subscription_userId_unique": { + "name": "user_subscription_userId_unique", + "columns": ["user_id"] + }, + "user_subscription_stripeSubscriptionId_unique": { + "name": "user_subscription_stripeSubscriptionId_unique", + "columns": ["stripe_subscription_id"] + } + }, + "checkConstraint": {} + } + }, + "views": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "tables": {}, + "indexes": {} + } +} diff --git a/apps/backend/src/core/db/migrations/meta/_journal.json b/apps/backend/src/core/db/migrations/meta/_journal.json index a992e398..9ae0dc25 100644 --- a/apps/backend/src/core/db/migrations/meta/_journal.json +++ b/apps/backend/src/core/db/migrations/meta/_journal.json @@ -183,6 +183,13 @@ "when": 1772619966391, "tag": "0025_aromatic_the_hood", "breakpoints": true + }, + { + "idx": 26, + "version": "5", + "when": 1773310064550, + "tag": "0026_greedy_patriot", + "breakpoints": true } ] } diff --git a/apps/backend/src/core/db/schemas/index.ts b/apps/backend/src/core/db/schemas/index.ts index b10e490c..e8330fc5 100644 --- a/apps/backend/src/core/db/schemas/index.ts +++ b/apps/backend/src/core/db/schemas/index.ts @@ -11,6 +11,10 @@ export { default as shortUrl, shortUrlRelations, } from '@/modules/url-shortener/domain/entities/short-url.entity'; +export { + default as shortUrlTag, + shortUrlTagRelations, +} from '@/modules/url-shortener/domain/entities/short-url-tag.entity'; export { default as customDomain, customDomainRelations, diff --git a/apps/backend/src/core/db/utils.ts b/apps/backend/src/core/db/utils.ts index 5d6b3279..b3709e6c 100644 --- a/apps/backend/src/core/db/utils.ts +++ b/apps/backend/src/core/db/utils.ts @@ -1,18 +1,20 @@ import { mysqlTableCreator, type MySqlTableWithColumns } from 'drizzle-orm/mysql-core'; import { type WhereConditions, type WhereField } from '../interface/repository.interface'; -import { and, eq, gt, gte, isNotNull, isNull, like, lt, lte, not, type SQL } from 'drizzle-orm'; +import { and, eq, gt, gte, isNotNull, isNull, like, lt, lte, not, or, type SQL } from 'drizzle-orm'; /** * Converts a where condition object to a Drizzle SQL object. * @param where The where condition object. * @param table The table schema. + * @param mode The combination mode: 'and' (default) combines conditions with AND, 'or' combines with OR. * @returns The Drizzle SQL object representing the where condition. */ export function convertWhereConditionToDrizzle( where: WhereConditions, - table: MySqlTableWithColumns, + mode: 'and' | 'or' = 'and', ): SQL | undefined { + const combine = mode === 'or' ? or : and; let sql: SQL | undefined; for (const [key, value] of Object.entries(where)) { @@ -22,40 +24,40 @@ export function convertWhereConditionToDrizzle( if (whereField.eq !== undefined) { if (whereField.eq === null) { - sql = sql ? and(sql, isNull(table[key])) : eq(table[key], null); + sql = sql ? combine(sql, isNull(table[key])) : isNull(table[key]); } else { - sql = sql ? and(sql, eq(table[key], whereField.eq)) : eq(table[key], whereField.eq); + sql = sql ? combine(sql, eq(table[key], whereField.eq)) : eq(table[key], whereField.eq); } } if (whereField.neq !== undefined) { if (whereField.neq === null) { - sql = sql ? and(sql, isNotNull(table[key])) : not(eq(table[key], null)); + sql = sql ? combine(sql, isNotNull(table[key])) : isNotNull(table[key]); } else { sql = sql - ? and(sql, not(eq(table[key], whereField.neq))) + ? combine(sql, not(eq(table[key], whereField.neq))) : not(eq(table[key], whereField.neq)); } } if (whereField.like !== undefined) { sql = sql - ? and(sql, like(table[key] as unknown as SQL, `%${whereField.like}%`)) + ? combine(sql, like(table[key] as unknown as SQL, `%${whereField.like}%`)) : like(table[key] as unknown as SQL, `%${whereField.like}%`); } if (whereField.gt !== undefined) { - sql = sql ? and(sql, gt(table[key], whereField.gt)) : gt(table[key], whereField.gt); + sql = sql ? combine(sql, gt(table[key], whereField.gt)) : gt(table[key], whereField.gt); } if (whereField.gte !== undefined) { - sql = sql ? and(sql, gte(table[key], whereField.gte)) : gte(table[key], whereField.gte); + sql = sql ? combine(sql, gte(table[key], whereField.gte)) : gte(table[key], whereField.gte); } if (whereField.lt !== undefined) { - sql = sql ? and(sql, lt(table[key], whereField.lt)) : lt(table[key], whereField.lt); + sql = sql ? combine(sql, lt(table[key], whereField.lt)) : lt(table[key], whereField.lt); } if (whereField.lte !== undefined) { - sql = sql ? and(sql, lte(table[key], whereField.lte)) : lte(table[key], whereField.lte); + sql = sql ? combine(sql, lte(table[key], whereField.lte)) : lte(table[key], whereField.lte); } } else { // If the value is not an object, it means it's a direct comparison value - sql = sql ? and(eq(table[key], value)) : eq(table[key], value); + sql = sql ? combine(sql, eq(table[key], value)) : eq(table[key], value); } } diff --git a/apps/backend/src/core/domain/strategies/base-image.strategy.ts b/apps/backend/src/core/domain/strategies/base-image.strategy.ts index 1bc1cdeb..17b009ba 100644 --- a/apps/backend/src/core/domain/strategies/base-image.strategy.ts +++ b/apps/backend/src/core/domain/strategies/base-image.strategy.ts @@ -36,6 +36,32 @@ export abstract class BaseImageStrategy { return userId ? `${folder}/${userId}/${fileName}` : `${folder}/${fileName}`; } + private static readonly extensionToMimeType: Record = { + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + png: 'image/png', + svg: 'image/svg+xml', + webp: 'image/webp', + }; + + /** + * Downloads an image from object storage and returns it as a base64 data URL. + * Useful for server-side rendering where JSDOM cannot load images from URLs. + */ + async getImageAsDataUrl(storagePath: string): Promise { + try { + const imageBuffer = await this.objectStorage.get(storagePath); + if (!imageBuffer) return undefined; + + const ext = storagePath.split('.').pop()?.toLowerCase() ?? ''; + const mimeType = BaseImageStrategy.extensionToMimeType[ext] ?? 'application/octet-stream'; + return `data:${mimeType};base64,${imageBuffer.toString('base64')}`; + } catch (error) { + this.logger.error('error.image.getAsDataUrl', { storagePath, error: error as Error }); + return undefined; + } + } + async getSignedUrl(imagePath: string): Promise { try { return await this.objectStorage.getSignedUrl(imagePath, this.signedUrlExpirySeconds); diff --git a/apps/backend/src/core/error/error-reporter.ts b/apps/backend/src/core/error/error-reporter.ts index 807c1c3f..f4ae1dff 100644 --- a/apps/backend/src/core/error/error-reporter.ts +++ b/apps/backend/src/core/error/error-reporter.ts @@ -1,7 +1,13 @@ import { singleton } from 'tsyringe'; import { env } from '../config/env'; import { OnShutdown } from '../decorators/on-shutdown.decorator'; -import { init, type NodeClient, captureException, isInitialized } from '@sentry/node'; +import { + init, + type NodeClient, + captureException, + isInitialized, + consoleLoggingIntegration, +} from '@sentry/node'; import { IN_PRODUCTION } from '../config/constants'; export type TErrorLevel = 'fatal' | 'error' | 'warning' | 'info'; @@ -23,6 +29,8 @@ export class ErrorReporter { dsn: env.SENTRY_DSN, profileSessionSampleRate: 1.0, environment: env.SENTRY_ENVIRONMENT, + enableLogs: true, + integrations: [consoleLoggingIntegration({ levels: ['log', 'warn', 'error'] })], }); } diff --git a/apps/backend/src/core/ip-protection/index.ts b/apps/backend/src/core/ip-protection/index.ts new file mode 100644 index 00000000..6a29c520 --- /dev/null +++ b/apps/backend/src/core/ip-protection/index.ts @@ -0,0 +1 @@ +export { IpAbuseTrackerService } from './ip-abuse-tracker.service'; diff --git a/apps/backend/src/core/ip-protection/ip-abuse-tracker.service.ts b/apps/backend/src/core/ip-protection/ip-abuse-tracker.service.ts new file mode 100644 index 00000000..37320c81 --- /dev/null +++ b/apps/backend/src/core/ip-protection/ip-abuse-tracker.service.ts @@ -0,0 +1,106 @@ +import { inject, singleton } from 'tsyringe'; +import { KeyCache } from '@/core/cache'; +import { Logger } from '@/core/logging'; + +interface IpAbuseTrackerConfig { + /** Number of unauthorized attempts before blocking */ + maxAttempts: number; + /** Time window in seconds for counting attempts */ + windowSeconds: number; + /** How long to keep the IP blocked in Redis (seconds) */ + blockTtlSeconds: number; + /** Redis key prefix for all ip-abuse keys */ + redisPrefix: string; +} + +const DEFAULT_CONFIG: IpAbuseTrackerConfig = { + maxAttempts: 5, + windowSeconds: 3600, // 1 hour + blockTtlSeconds: 7 * 24 * 3600, // 7 days + redisPrefix: 'ip_abuse:', +}; + +/** + * Tracks abusive IP addresses that repeatedly fail authentication + * and blocks them via Redis. + * + * Flow: + * 1. Each unauthorized request increments a per-IP counter in Redis. + * 2. Once the counter reaches {@link IpAbuseTrackerConfig.maxAttempts} within + * the time window, the IP is marked as blocked in Redis. + * 3. Subsequent requests from a blocked IP are rejected early via {@link isBlocked}. + */ +@singleton() +export class IpAbuseTrackerService { + private readonly config: IpAbuseTrackerConfig; + private readonly isTestEnvironment: boolean; + + constructor( + @inject(KeyCache) private readonly cache: KeyCache, + @inject(Logger) private readonly logger: Logger, + ) { + this.config = DEFAULT_CONFIG; + this.isTestEnvironment = process.env.NODE_ENV === 'test'; + } + + /** + * Record an unauthorized request from the given IP. + * If the threshold is reached, the IP is blocked. + */ + async trackUnauthorizedAttempt(ip: string | undefined): Promise { + if (!ip || this.isTestEnvironment) return; + + try { + const attemptsKey = `${this.config.redisPrefix}attempts:${ip}`; + const client = this.cache.getClient(); + + const attempts = await client.incr(attemptsKey); + if (attempts === 1) { + await client.expire(attemptsKey, this.config.windowSeconds); + } + + this.logger.debug('ip.abuse.attempt', { ip, attempts }); + + if (attempts >= this.config.maxAttempts) { + await this.blockIp(ip); + } + } catch (error) { + this.logger.error('ip.abuse.tracking.failed', { error: error as Error, ip }); + } + } + + /** + * Check whether an IP is currently blocked. + * Uses a fast Redis key lookup (O(1)). + */ + async isBlocked(ip: string | undefined): Promise { + if (!ip || this.isTestEnvironment) return false; + + try { + const blockedKey = `${this.config.redisPrefix}blocked:${ip}`; + return (await this.cache.get(blockedKey)) !== null; + } catch (error) { + this.logger.error('ip.abuse.check.failed', { error: error as Error, ip }); + return false; + } + } + + private async blockIp(ip: string): Promise { + const blockedKey = `${this.config.redisPrefix}blocked:${ip}`; + + // Already blocked - skip + if ((await this.cache.get(blockedKey)) !== null) return; + + // Mark as blocked in Redis + await this.cache.set(blockedKey, '1', this.config.blockTtlSeconds); + + this.logger.warn('ip.abuse.blocked', { + ip, + reason: 'Too many unauthorized requests', + config: { + maxAttempts: this.config.maxAttempts, + windowSeconds: this.config.windowSeconds, + }, + }); + } +} diff --git a/apps/backend/src/core/logging/logger.ts b/apps/backend/src/core/logging/logger.ts index 6dc9dcf5..82e08b37 100644 --- a/apps/backend/src/core/logging/logger.ts +++ b/apps/backend/src/core/logging/logger.ts @@ -3,6 +3,7 @@ import pino, { TransportTargetOptions, type Logger as PinoLogger, } from 'pino'; +import { logger as sentryLogger } from '@sentry/node'; import { singleton } from 'tsyringe'; import { env } from '@/core/config/env'; import { type ILogger } from '../interface/logger.interface'; @@ -58,21 +59,26 @@ export class Logger implements ILogger { debug(message: string, obj?: object): void { this.logger.debug(obj, message); + sentryLogger.debug(message, obj as Record); } info(message: string, obj?: object): void { this.logger.info(obj, message); + sentryLogger.info(message, obj as Record); } warn(message: string, obj?: object): void { this.logger.warn(obj, message); + sentryLogger.warn(message, obj as Record); } error(message: string, obj?: object): void { this.logger.error(obj, message); + sentryLogger.error(message, obj as Record); } fatal(message: string, obj?: object): void { this.logger.fatal(obj, message); + sentryLogger.fatal(message, obj as Record); } } diff --git a/apps/backend/src/core/server.ts b/apps/backend/src/core/server.ts index aa7a132b..6ba6d458 100644 --- a/apps/backend/src/core/server.ts +++ b/apps/backend/src/core/server.ts @@ -15,6 +15,7 @@ import { registerRoutes, resolveClientIp, } from '@/libs/fastify/helpers'; +import { addUserToRequestMiddleware } from '@/core/http/middleware/add-user-to-request.middleware'; import { env } from './config/env'; import fastifyHelmet from '@fastify/helmet'; import { TooManyRequestsError } from './error/http/too-many-requests.error'; @@ -30,6 +31,7 @@ import multipart from '@fastify/multipart'; import { resolveRateLimit } from './rate-limit/rate-limit.resolver'; import { RateLimitPolicy } from './rate-limit/rate-limit.policy'; import { KeyCache } from './cache'; +import { IpAbuseTrackerService } from './ip-protection'; @singleton() export class Server { @@ -112,6 +114,9 @@ export class Server { secret: env.COOKIE_SECRET, }); + // Add middleware to attach user info to request before all handlers + this.server.addHook('preHandler', addUserToRequestMiddleware); + if (!IN_TEST) { await this.server.register(fastifyRateLimit, { hook: 'preHandler', @@ -155,6 +160,14 @@ export class Server { done(); }); + this.server.addHook('onRequest', async (request, reply) => { + const blocked = await container.resolve(IpAbuseTrackerService).isBlocked(request.clientIp); + + if (blocked) { + return reply.status(403).send({ message: 'Access denied', code: 403 }); + } + }); + await this.server.register(fastifyHelmet); registerRoutes(this.server, HealthController, API_BASE_PATH); diff --git a/apps/backend/src/libs/fastify/helpers.ts b/apps/backend/src/libs/fastify/helpers.ts index fa0b2c2a..776a562c 100644 --- a/apps/backend/src/libs/fastify/helpers.ts +++ b/apps/backend/src/libs/fastify/helpers.ts @@ -8,10 +8,11 @@ import { type RouteOptions, } from 'fastify'; import { deepMerge, mergeZodErrorObjects } from '@/utils/general'; -import { BadRequestError, CustomApiError } from '@/core/error/http'; +import { BadRequestError, CustomApiError, UnauthorizedError } from '@/core/error/http'; import { container, type InjectionToken } from 'tsyringe'; import { Logger } from '@/core/logging'; import { ErrorReporter } from '@/core/error'; +import { IpAbuseTrackerService } from '@/core/ip-protection'; import { type IHttpRequest } from '@/core/interface/request.interface'; import { ROUTE_METADATA_KEY, type RouteMetadata } from '@/core/decorators/route'; import { type IHttpResponse } from '@/core/interface/response.interface'; @@ -21,7 +22,6 @@ import z, { type ZodType } from 'zod'; import qs from 'qs'; import { UnhandledServerError } from '@/core/error/http/unhandled-server.error'; import { $ZodError } from 'zod/v4/core'; -import { addUserToRequestMiddleware } from '@/core/http/middleware/add-user-to-request.middleware'; export const fastifyRequestParser = ( request: FastifyRequest & { user?: { id: string } }, @@ -66,6 +66,13 @@ export const fastifyErrorHandler = ( }, }); + if (error instanceof UnauthorizedError) { + container + .resolve(IpAbuseTrackerService) + .trackUnauthorizedAttempt(_request.clientIp) + .catch((err) => logger.error('ip.abuse.tracking.error', { error: err as Error })); + } + return reply.status(error.statusCode).send(responsePayload); } @@ -209,7 +216,17 @@ export function registerRoutes( schema: deepMerge(schema, routeMeta.options.schema as unknown as Partial), }; - routeOptions.preHandler = [addUserToRequestMiddleware]; + routeOptions.preHandler = []; + if (routeMeta.options.bodySchema) { + routeOptions.preHandler.push( + createValidationHook(routeMeta.options.bodySchema, 'Invalid request body', 'body'), + ); + } + if (routeMeta.options.querySchema) { + routeOptions.preHandler.push( + createValidationHook(routeMeta.options.querySchema, 'Invalid query params', 'query'), + ); + } if (typeof routeMeta.options.authHandler === 'undefined') { routeOptions.preHandler.push(defaultApiAuthMiddleware); @@ -223,21 +240,6 @@ export function registerRoutes( // no-op: skip authentication for this route } - const preValidation: ReturnType[] = []; - if (routeMeta.options.bodySchema) { - preValidation.push( - createValidationHook(routeMeta.options.bodySchema, 'Invalid request body', 'body'), - ); - } - if (routeMeta.options.querySchema) { - preValidation.push( - createValidationHook(routeMeta.options.querySchema, 'Invalid query params', 'query'), - ); - } - if (preValidation.length > 0) { - routeOptions.preValidation = preValidation; - } - fastify.route(routeOptions); }); } diff --git a/apps/backend/src/modules/billing/service/__tests__/stripe-webhook.service.test.ts b/apps/backend/src/modules/billing/service/__tests__/stripe-webhook.service.test.ts index 58a0915c..f4a876b2 100644 --- a/apps/backend/src/modules/billing/service/__tests__/stripe-webhook.service.test.ts +++ b/apps/backend/src/modules/billing/service/__tests__/stripe-webhook.service.test.ts @@ -20,6 +20,7 @@ const mockRepository = { upsertByStripeSubscriptionId: jest.fn(), update: jest.fn(), clearCancellationNotifications: jest.fn(), + generateId: jest.fn().mockReturnValue('generated-test-id'), } as unknown as jest.Mocked; // Mock StripeService diff --git a/apps/backend/src/modules/billing/service/stripe-webhook.service.ts b/apps/backend/src/modules/billing/service/stripe-webhook.service.ts index fbfa581a..20c80065 100644 --- a/apps/backend/src/modules/billing/service/stripe-webhook.service.ts +++ b/apps/backend/src/modules/billing/service/stripe-webhook.service.ts @@ -109,7 +109,7 @@ export class StripeWebhookService { const { periodStart, periodEnd } = await this.getSubscriptionPeriod(subscription); await this.userSubscriptionRepository.upsertByStripeSubscriptionId({ - id: crypto.randomUUID(), + id: this.userSubscriptionRepository.generateId(), userId, stripeCustomerId: customerId, stripeSubscriptionId: subscriptionId, diff --git a/apps/backend/src/modules/config-template/domain/strategies/config-template-image.strategy.ts b/apps/backend/src/modules/config-template/domain/strategies/config-template-image.strategy.ts index 478ebc4c..4801923a 100644 --- a/apps/backend/src/modules/config-template/domain/strategies/config-template-image.strategy.ts +++ b/apps/backend/src/modules/config-template/domain/strategies/config-template-image.strategy.ts @@ -57,8 +57,15 @@ export class ConfigTemplateImageStrategy extends BaseImageStrategy { fileName, ); - const instance = await generateQrCodeStylingInstance({ - ...convertQrCodeOptionsToLibraryOptions(config), + const libraryOptions = convertQrCodeOptionsToLibraryOptions(config); + + // Convert S3 storage path to a base64 data URL so JSDOM can handle it + if (libraryOptions.image) { + libraryOptions.image = (await this.getImageAsDataUrl(libraryOptions.image)) ?? undefined; + } + + const instance = generateQrCodeStylingInstance({ + ...libraryOptions, data: 'https://www.qrcodly.de/', }); diff --git a/apps/backend/src/modules/config-template/event/handler/config-template-created.event-handler.ts b/apps/backend/src/modules/config-template/event/handler/config-template-created.event-handler.ts index 8fddc975..f6456786 100644 --- a/apps/backend/src/modules/config-template/event/handler/config-template-created.event-handler.ts +++ b/apps/backend/src/modules/config-template/event/handler/config-template-created.event-handler.ts @@ -23,17 +23,6 @@ export class ConfigTemplateCreatedEventHandler extends AbstractEventHandler { expect(response.statusCode).toBe(400); }); + it('should reject URL with invalid hostname (no TLD)', async () => { + const invalidUrlDto: TCreateQrCodeDto = { + ...generateQrCodeDto(), + content: { + type: 'url' as const, + data: { + url: 'https://abcde', + isEditable: false, + }, + }, + }; + const response = await createRequest(invalidUrlDto, accessToken); + expect(response.statusCode).toBe(400); + }); + it('should reject URL exceeding max length (1000 chars)', async () => { const longUrl = 'https://example.com/' + 'a'.repeat(1000); const invalidUrlDto: TCreateQrCodeDto = { diff --git a/apps/backend/src/modules/qr-code/http/__tests__/update-qr-code-url.test.ts b/apps/backend/src/modules/qr-code/http/__tests__/update-qr-code-url.test.ts index ae4340f5..2365dd5c 100644 --- a/apps/backend/src/modules/qr-code/http/__tests__/update-qr-code-url.test.ts +++ b/apps/backend/src/modules/qr-code/http/__tests__/update-qr-code-url.test.ts @@ -250,6 +250,26 @@ describe('updateQrCode - URL Content Type', () => { expect(response.statusCode).toBe(400); }); + it('should reject URL with invalid hostname (no TLD)', async () => { + const createdQrCode = await createQrCode(generateQrCodeDto(), accessToken); + + const response = await updateQrCodeRequest( + createdQrCode.id, + { + content: { + type: 'url', + data: { + url: 'https://abcde', + isEditable: false, + }, + }, + }, + accessToken, + ); + + expect(response.statusCode).toBe(400); + }); + it('should reject empty URL', async () => { const createdQrCode = await createQrCode(generateQrCodeDto(), accessToken); diff --git a/apps/backend/src/modules/qr-code/http/__tests__/update-qr-code.test.ts b/apps/backend/src/modules/qr-code/http/__tests__/update-qr-code.test.ts index 017dd659..6926cc44 100644 --- a/apps/backend/src/modules/qr-code/http/__tests__/update-qr-code.test.ts +++ b/apps/backend/src/modules/qr-code/http/__tests__/update-qr-code.test.ts @@ -28,6 +28,7 @@ describe('updateQrCode', () => { payload: dto, headers: { Authorization: `Bearer ${token}` }, }); + expect(response.statusCode).toBe(201); return JSON.parse(response.payload) as TQrCodeWithRelationsResponseDto; }; diff --git a/apps/backend/src/modules/qr-code/http/__tests__/utils.ts b/apps/backend/src/modules/qr-code/http/__tests__/utils.ts index 9971405c..dae5f9ad 100644 --- a/apps/backend/src/modules/qr-code/http/__tests__/utils.ts +++ b/apps/backend/src/modules/qr-code/http/__tests__/utils.ts @@ -49,7 +49,7 @@ export const createQrCodeRequest = async ( * Generates a new random QR code DTO. */ export const generateQrCodeDto = (): TCreateQrCodeDto => ({ - name: faker.lorem.words(3), + name: faker.lorem.words(3).substring(0, 32), content: { type: 'url', data: { @@ -68,7 +68,7 @@ export const generateEventQrCodeDto = (): TCreateQrCodeDto => { const endDate = faker.date.future({ refDate: startDate }); return { - name: faker.lorem.words(3), + name: faker.lorem.words(3).substring(0, 32), content: { type: 'event', data: { @@ -87,7 +87,7 @@ export const generateEventQrCodeDto = (): TCreateQrCodeDto => { * Generates a WiFi QR code DTO. */ export const generateWifiQrCodeDto = (): TCreateQrCodeDto => ({ - name: faker.lorem.words(3), + name: faker.lorem.words(3).substring(0, 32), content: { type: 'wifi', data: { @@ -103,7 +103,7 @@ export const generateWifiQrCodeDto = (): TCreateQrCodeDto => ({ * Generates a vCard QR code DTO. */ export const generateVCardQrCodeDto = (): TCreateQrCodeDto => ({ - name: faker.lorem.words(3), + name: faker.lorem.words(3).substring(0, 32), content: { type: 'vCard', data: { @@ -121,7 +121,7 @@ export const generateVCardQrCodeDto = (): TCreateQrCodeDto => ({ * Generates a text QR code DTO. */ export const generateTextQrCodeDto = (): TCreateQrCodeDto => ({ - name: faker.lorem.words(3), + name: faker.lorem.words(3).substring(0, 32), content: { type: 'text', data: faker.lorem.paragraph(), @@ -133,7 +133,7 @@ export const generateTextQrCodeDto = (): TCreateQrCodeDto => ({ * Generates an editable URL QR code DTO. */ export const generateEditableUrlQrCodeDto = (): TCreateQrCodeDto => ({ - name: faker.lorem.words(3), + name: faker.lorem.words(3).substring(0, 32), content: { type: 'url', data: { @@ -148,7 +148,7 @@ export const generateEditableUrlQrCodeDto = (): TCreateQrCodeDto => ({ * Generates a dynamic vCard QR code DTO. */ export const generateDynamicVCardQrCodeDto = (): TCreateQrCodeDto => ({ - name: faker.lorem.words(3), + name: faker.lorem.words(3).substring(0, 32), content: { type: 'vCard', data: { @@ -167,7 +167,7 @@ export const generateDynamicVCardQrCodeDto = (): TCreateQrCodeDto => ({ * Generates an email QR code DTO. */ export const generateEmailQrCodeDto = (): TCreateQrCodeDto => ({ - name: faker.lorem.words(3), + name: faker.lorem.words(3).substring(0, 32), content: { type: 'email', data: { @@ -183,7 +183,7 @@ export const generateEmailQrCodeDto = (): TCreateQrCodeDto => ({ * Generates a location QR code DTO. */ export const generateLocationQrCodeDto = (): TCreateQrCodeDto => ({ - name: faker.lorem.words(3), + name: faker.lorem.words(3).substring(0, 32), content: { type: 'location', data: { @@ -199,7 +199,7 @@ export const generateLocationQrCodeDto = (): TCreateQrCodeDto => ({ * Generates an EPC (SEPA bank transfer) QR code DTO. */ export const generateEpcQrCodeDto = (): TCreateQrCodeDto => ({ - name: faker.lorem.words(3), + name: faker.lorem.words(3).substring(0, 32), content: { type: 'epc', data: { diff --git a/apps/backend/src/modules/qr-code/lib/styled-qr-code/index.ts b/apps/backend/src/modules/qr-code/lib/styled-qr-code/index.ts index 0079fe2d..5399c19e 100644 --- a/apps/backend/src/modules/qr-code/lib/styled-qr-code/index.ts +++ b/apps/backend/src/modules/qr-code/lib/styled-qr-code/index.ts @@ -1,14 +1,26 @@ import QRCodeStyling, { type Options } from 'qr-code-styling'; import { JSDOM } from 'jsdom'; +// Minimal nodeCanvas implementation for server-side image loading. +// JSDOM's Image element never fires onload (it can't decode images), +// which causes qr-code-styling's loadImage() promise to hang forever. +// Providing nodeCanvas.loadImage makes the library use this path instead. +const nodeCanvas = { + loadImage: (src: string) => Promise.resolve({ width: 100, height: 100, src } as any), + createCanvas: () => null, +}; + export function generateQrCodeStylingInstance(options: Options) { - // For svg type with the inner-image saved as a blob - // (inner-image will render in more places but file will be larger) - const qrCodeSvgWithBlobImage = new QRCodeStyling({ - jsdom: JSDOM, // this is required + return new QRCodeStyling({ + jsdom: JSDOM, + nodeCanvas: nodeCanvas as any, ...options, type: 'svg', + // Disable saveAsBlob for server-side rendering. When enabled, qr-code-styling + // uses JSDOM's XMLHttpRequest with responseType="blob" which JSDOM doesn't support. + imageOptions: { + ...options.imageOptions, + saveAsBlob: false, + }, }); - - return qrCodeSvgWithBlobImage; } diff --git a/apps/backend/src/modules/tag/domain/entities/tag.entity.ts b/apps/backend/src/modules/tag/domain/entities/tag.entity.ts index 9d82bd54..a7dcd64d 100644 --- a/apps/backend/src/modules/tag/domain/entities/tag.entity.ts +++ b/apps/backend/src/modules/tag/domain/entities/tag.entity.ts @@ -2,6 +2,7 @@ import { createTable } from '@/core/db/utils'; import { relations } from 'drizzle-orm'; import { datetime, index, uniqueIndex, varchar } from 'drizzle-orm/mysql-core'; import qrCodeTag from './qr-code-tag.entity'; +import shortUrlTag from '@/modules/url-shortener/domain/entities/short-url-tag.entity'; const tag = createTable( 'tag', @@ -26,4 +27,5 @@ export default tag; export const tagRelations = relations(tag, ({ many }) => ({ qrCodeTags: many(qrCodeTag), + shortUrlTags: many(shortUrlTag), })); diff --git a/apps/backend/src/modules/tag/domain/repository/tag.repository.ts b/apps/backend/src/modules/tag/domain/repository/tag.repository.ts index 5a36d342..5e53d1be 100644 --- a/apps/backend/src/modules/tag/domain/repository/tag.repository.ts +++ b/apps/backend/src/modules/tag/domain/repository/tag.repository.ts @@ -1,9 +1,10 @@ import { singleton } from 'tsyringe'; -import { count, desc, eq } from 'drizzle-orm'; +import { count, desc, eq, inArray } from 'drizzle-orm'; import AbstractRepository from '@/core/domain/repository/abstract.repository'; import { type ISqlQueryFindBy } from '@/core/interface/repository.interface'; import tag, { type TTag } from '../entities/tag.entity'; import qrCodeTag from '../entities/qr-code-tag.entity'; +import shortUrlTag from '@/modules/url-shortener/domain/entities/short-url-tag.entity'; @singleton() class TagRepository extends AbstractRepository { @@ -79,6 +80,51 @@ class TagRepository extends AbstractRepository { }); } + async findTagsByShortUrlId(shortUrlId: string): Promise { + const rows = await this.db + .select({ tag: this.table }) + .from(this.table) + .innerJoin(shortUrlTag, eq(this.table.id, shortUrlTag.tagId)) + .where(eq(shortUrlTag.shortUrlId, shortUrlId)) + .orderBy(desc(this.table.createdAt)) + .execute(); + + return rows.map((row) => row.tag); + } + + async findTagsByShortUrlIds(shortUrlIds: string[]): Promise> { + if (shortUrlIds.length === 0) return new Map(); + + const rows = await this.db + .select({ shortUrlId: shortUrlTag.shortUrlId, tag: this.table }) + .from(this.table) + .innerJoin(shortUrlTag, eq(this.table.id, shortUrlTag.tagId)) + .where(inArray(shortUrlTag.shortUrlId, shortUrlIds)) + .orderBy(desc(this.table.createdAt)) + .execute(); + + const map = new Map(); + for (const row of rows) { + const existing = map.get(row.shortUrlId) ?? []; + existing.push(row.tag); + map.set(row.shortUrlId, existing); + } + return map; + } + + async setShortUrlTags(shortUrlId: string, tagIds: string[]): Promise { + await this.db.transaction(async (tx) => { + await tx.delete(shortUrlTag).where(eq(shortUrlTag.shortUrlId, shortUrlId)).execute(); + + if (tagIds.length > 0) { + await tx + .insert(shortUrlTag) + .values(tagIds.map((tagId) => ({ shortUrlId, tagId }))) + .execute(); + } + }); + } + async getQrCodeCountsByTagId(userId: string): Promise> { const rows = await this.db .select({ diff --git a/apps/backend/src/modules/tag/error/http/max-tags-exceeded.error.ts b/apps/backend/src/modules/tag/error/http/max-tags-exceeded.error.ts new file mode 100644 index 00000000..303de59d --- /dev/null +++ b/apps/backend/src/modules/tag/error/http/max-tags-exceeded.error.ts @@ -0,0 +1,9 @@ +import { BadRequestError } from '@/core/error/http/bad-request.error'; + +export const MAX_TAGS_PER_RESOURCE = 3; + +export class MaxTagsExceededError extends BadRequestError { + constructor() { + super(`You can add a maximum of ${MAX_TAGS_PER_RESOURCE} tags.`); + } +} diff --git a/apps/backend/src/modules/tag/http/__tests__/set-qr-code-tags.test.ts b/apps/backend/src/modules/tag/http/__tests__/set-qr-code-tags.test.ts index 9818ad4f..0f3073a8 100644 --- a/apps/backend/src/modules/tag/http/__tests__/set-qr-code-tags.test.ts +++ b/apps/backend/src/modules/tag/http/__tests__/set-qr-code-tags.test.ts @@ -1,13 +1,11 @@ import { getTestContext } from '@/tests/shared/test-context'; import { type FastifyInstance } from 'fastify'; import { TAG_API_PATH, createTagRequest, createQrCodeForTest } from './utils'; -import { ensureProSubscription } from '@/tests/shared/helpers'; describe('setQrCodeTags', () => { let testServer: FastifyInstance; let accessToken: string; let accessToken2: string; - let accessTokenPro: string; const setQrCodeTagsRequest = async (qrCodeId: string, token?: string, body?: any) => testServer.inject({ @@ -24,8 +22,6 @@ describe('setQrCodeTags', () => { testServer = ctx.testServer; accessToken = ctx.accessToken; accessToken2 = ctx.accessToken2; - accessTokenPro = ctx.accessTokenPro; - await ensureProSubscription(); }); it('should set tags on a QR code', async () => { @@ -97,56 +93,52 @@ describe('setQrCodeTags', () => { expect(response.statusCode).toBe(403); }); - it('should return 403 when free user tries to set more than 1 tag', async () => { + it('should allow any user to set up to 3 tags', async () => { const tag1 = await createTagRequest( testServer, - { name: 'Limit Tag A ' + Date.now() }, + { name: 'Multi Tag A ' + Date.now() }, accessToken, ); const tag2 = await createTagRequest( testServer, - { name: 'Limit Tag B ' + Date.now() }, + { name: 'Multi Tag B ' + Date.now() }, accessToken, ); - const qrCode = await createQrCodeForTest( + const tag3 = await createTagRequest( testServer, + { name: 'Multi Tag C ' + Date.now() }, accessToken, - 'Limit Test QR ' + Date.now(), ); + const qrCode = await createQrCodeForTest(testServer, accessToken, 'Multi Tag QR ' + Date.now()); const response = await setQrCodeTagsRequest(qrCode.id, accessToken, { - tagIds: [tag1.id, tag2.id], + tagIds: [tag1.id, tag2.id, tag3.id], }); - expect(response.statusCode).toBe(403); - const body = JSON.parse(response.payload); - expect(body.message).toContain('Plan limit exceeded'); + expect(response.statusCode).toBe(200); + const tags = JSON.parse(response.payload); + expect(tags).toHaveLength(3); }); - it('should allow pro user to set multiple tags', async () => { - const tag1 = await createTagRequest( - testServer, - { name: 'Pro Tag A ' + Date.now() }, - accessTokenPro, - ); - const tag2 = await createTagRequest( - testServer, - { name: 'Pro Tag B ' + Date.now() }, - accessTokenPro, + it('should return 400 when trying to set more than 3 tags', async () => { + const tags = await Promise.all( + Array.from({ length: 4 }, (_, i) => + createTagRequest(testServer, { name: `Limit Tag ${i} ` + Date.now() }, accessToken), + ), ); const qrCode = await createQrCodeForTest( testServer, - accessTokenPro, - 'Pro Tag QR ' + Date.now(), + accessToken, + 'Limit Test QR ' + Date.now(), ); - const response = await setQrCodeTagsRequest(qrCode.id, accessTokenPro, { - tagIds: [tag1.id, tag2.id], + const response = await setQrCodeTagsRequest(qrCode.id, accessToken, { + tagIds: tags.map((t) => t.id), }); - expect(response.statusCode).toBe(200); - const tags = JSON.parse(response.payload); - expect(tags).toHaveLength(2); + expect(response.statusCode).toBe(400); + const body = JSON.parse(response.payload); + expect(body.message).toContain('You can add a maximum of 3 tags'); }); it('should replace existing tags with new ones', async () => { diff --git a/apps/backend/src/modules/tag/http/__tests__/set-short-url-tags.test.ts b/apps/backend/src/modules/tag/http/__tests__/set-short-url-tags.test.ts new file mode 100644 index 00000000..a93bd233 --- /dev/null +++ b/apps/backend/src/modules/tag/http/__tests__/set-short-url-tags.test.ts @@ -0,0 +1,223 @@ +import { getTestContext } from '@/tests/shared/test-context'; +import { type FastifyInstance } from 'fastify'; +import { TAG_API_PATH, createTagRequest } from './utils'; +import { createShortUrl } from '@/modules/url-shortener/http/__tests__/utils'; +import { generateEditableUrlQrCodeDto } from '@/modules/qr-code/http/__tests__/utils'; +import { API_BASE_PATH } from '@/core/config/constants'; +import type { TQrCodeWithRelationsResponseDto } from '@shared/schemas'; + +describe('setShortUrlTags', () => { + let testServer: FastifyInstance; + let accessToken: string; + let accessToken2: string; + + const setShortUrlTagsRequest = async (shortUrlId: string, token?: string, body?: any) => + testServer.inject({ + method: 'PUT', + url: `${TAG_API_PATH}/short-url/${shortUrlId}`, + headers: { + Authorization: token ? `Bearer ${token}` : '', + }, + ...(body ? { payload: body } : {}), + }); + + beforeAll(async () => { + const ctx = await getTestContext(); + testServer = ctx.testServer; + accessToken = ctx.accessToken; + accessToken2 = ctx.accessToken2; + }); + + it('should set tags on a short URL', async () => { + const tag = await createTagRequest(testServer, { name: 'SU Tag ' + Date.now() }, accessToken); + const shortUrl = await createShortUrl(testServer, accessToken); + + const response = await setShortUrlTagsRequest(shortUrl.id, accessToken, { + tagIds: [tag.id], + }); + + expect(response.statusCode).toBe(200); + const tags = JSON.parse(response.payload); + expect(Array.isArray(tags)).toBe(true); + expect(tags.length).toBe(1); + expect(tags[0].id).toBe(tag.id); + }); + + it('should clear tags when empty array is passed', async () => { + const shortUrl = await createShortUrl(testServer, accessToken); + + const response = await setShortUrlTagsRequest(shortUrl.id, accessToken, { tagIds: [] }); + + expect(response.statusCode).toBe(200); + const tags = JSON.parse(response.payload); + expect(tags).toHaveLength(0); + }); + + it('should return 401 without auth token', async () => { + const shortUrl = await createShortUrl(testServer, accessToken); + + const response = await setShortUrlTagsRequest(shortUrl.id, undefined, { tagIds: [] }); + + expect(response.statusCode).toBe(401); + }); + + it('should return 404 for non-existent short URL', async () => { + const response = await setShortUrlTagsRequest( + '00000000-0000-0000-0000-000000000000', + accessToken, + { tagIds: [] }, + ); + + expect(response.statusCode).toBe(404); + }); + + it('should return 403 when setting tags on another user short URL', async () => { + const tag = await createTagRequest( + testServer, + { name: 'IDOR SU Tag ' + Date.now() }, + accessToken, + ); + const shortUrl = await createShortUrl(testServer, accessToken2); + + const response = await setShortUrlTagsRequest(shortUrl.id, accessToken, { + tagIds: [tag.id], + }); + + expect(response.statusCode).toBe(403); + }); + + it('should return 403 when using another user tags', async () => { + const user2Tag = await createTagRequest( + testServer, + { name: 'User2 SU Tag IDOR ' + Date.now() }, + accessToken2, + ); + const shortUrl = await createShortUrl(testServer, accessToken); + + const response = await setShortUrlTagsRequest(shortUrl.id, accessToken, { + tagIds: [user2Tag.id], + }); + + expect(response.statusCode).toBe(403); + }); + + it('should allow any user to set up to 3 tags', async () => { + const tag1 = await createTagRequest( + testServer, + { name: 'SU Multi A ' + Date.now() }, + accessToken, + ); + const tag2 = await createTagRequest( + testServer, + { name: 'SU Multi B ' + Date.now() }, + accessToken, + ); + const tag3 = await createTagRequest( + testServer, + { name: 'SU Multi C ' + Date.now() }, + accessToken, + ); + const shortUrl = await createShortUrl(testServer, accessToken); + + const response = await setShortUrlTagsRequest(shortUrl.id, accessToken, { + tagIds: [tag1.id, tag2.id, tag3.id], + }); + + expect(response.statusCode).toBe(200); + const tags = JSON.parse(response.payload); + expect(tags).toHaveLength(3); + }); + + it('should return 400 when trying to set more than 3 tags', async () => { + const tags = await Promise.all( + Array.from({ length: 4 }, (_, i) => + createTagRequest(testServer, { name: `SU Limit ${i} ` + Date.now() }, accessToken), + ), + ); + const shortUrl = await createShortUrl(testServer, accessToken); + + const response = await setShortUrlTagsRequest(shortUrl.id, accessToken, { + tagIds: tags.map((t) => t.id), + }); + + expect(response.statusCode).toBe(400); + const body = JSON.parse(response.payload); + expect(body.message).toContain('You can add a maximum of 3 tags'); + }); + + it('should replace existing tags with new ones', async () => { + const tag1 = await createTagRequest( + testServer, + { name: 'SU Replace A ' + Date.now() }, + accessToken, + ); + const tag2 = await createTagRequest( + testServer, + { name: 'SU Replace B ' + Date.now() }, + accessToken, + ); + const shortUrl = await createShortUrl(testServer, accessToken); + + // Set first tag + await setShortUrlTagsRequest(shortUrl.id, accessToken, { tagIds: [tag1.id] }); + + // Replace with second tag + const response = await setShortUrlTagsRequest(shortUrl.id, accessToken, { + tagIds: [tag2.id], + }); + + expect(response.statusCode).toBe(200); + const tags = JSON.parse(response.payload); + expect(tags).toHaveLength(1); + expect(tags[0].id).toBe(tag2.id); + }); + + it('should default to empty tags when tagIds is omitted', async () => { + const tag = await createTagRequest( + testServer, + { name: 'SU Default Tag ' + Date.now() }, + accessToken, + ); + const shortUrl = await createShortUrl(testServer, accessToken); + + // First set a tag + await setShortUrlTagsRequest(shortUrl.id, accessToken, { tagIds: [tag.id] }); + + // Then send empty body (tagIds defaults to []) + const response = await setShortUrlTagsRequest(shortUrl.id, accessToken, {}); + + expect(response.statusCode).toBe(200); + const tags = JSON.parse(response.payload); + expect(tags).toHaveLength(0); + }); + + it('should return 400 when trying to set tags on a QR-code-linked short URL', async () => { + const tag = await createTagRequest( + testServer, + { name: 'QR Linked Tag ' + Date.now() }, + accessToken, + ); + + // Create a dynamic URL QR code (which has a linked short URL) + const createResponse = await testServer.inject({ + method: 'POST', + url: `${API_BASE_PATH}/qr-code`, + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, + }, + payload: generateEditableUrlQrCodeDto(), + }); + expect(createResponse.statusCode).toBe(201); + const qrCode = JSON.parse(createResponse.payload) as TQrCodeWithRelationsResponseDto; + expect(qrCode.shortUrl).not.toBeNull(); + + const response = await setShortUrlTagsRequest(qrCode.shortUrl!.id, accessToken, { + tagIds: [tag.id], + }); + + expect(response.statusCode).toBe(400); + const body = JSON.parse(response.payload); + expect(body.message).toContain('linked to a QR code'); + }); +}); diff --git a/apps/backend/src/modules/tag/http/controller/tag.controller.ts b/apps/backend/src/modules/tag/http/controller/tag.controller.ts index 8bffe760..098408a1 100644 --- a/apps/backend/src/modules/tag/http/controller/tag.controller.ts +++ b/apps/backend/src/modules/tag/http/controller/tag.controller.ts @@ -6,6 +6,7 @@ import { UpdateTagUseCase } from '../../useCase/update-tag.use-case'; import { DeleteTagUseCase } from '../../useCase/delete-tag.use-case'; import { ListTagsUseCase } from '../../useCase/list-tags.use-case'; import { SetQrCodeTagsUseCase } from '../../useCase/set-qr-code-tags.use-case'; +import { SetShortUrlTagsUseCase } from '../../useCase/set-short-url-tags.use-case'; import TagRepository from '../../domain/repository/tag.repository'; import { type TTag } from '../../domain/entities/tag.entity'; import { TagNotFoundError } from '../../error/http/tag-not-found.error'; @@ -15,12 +16,14 @@ import { CreateTagDto, GetTagQueryParamsSchema, SetQrCodeTagsDto, + SetShortUrlTagsDto, TagPaginatedResponseDto, TagResponseDto, TCreateTagDto, TGetTagQueryParamsDto, TIdRequestQueryDto, TSetQrCodeTagsDto, + TSetShortUrlTagsDto, TTagPaginatedResponseDto, TTagResponseDto, TUpdateTagDto, @@ -39,6 +42,8 @@ export class TagController extends AbstractController { @inject(UpdateTagUseCase) private readonly updateTagUseCase: UpdateTagUseCase, @inject(DeleteTagUseCase) private readonly deleteTagUseCase: DeleteTagUseCase, @inject(SetQrCodeTagsUseCase) private readonly setQrCodeTagsUseCase: SetQrCodeTagsUseCase, + @inject(SetShortUrlTagsUseCase) + private readonly setShortUrlTagsUseCase: SetShortUrlTagsUseCase, @inject(TagRepository) private readonly tagRepository: TagRepository, ) { super(); @@ -183,6 +188,35 @@ export class TagController extends AbstractController { ); } + @Put('/short-url/:id', { + bodySchema: SetShortUrlTagsDto, + responseSchema: { + 200: z.array(TagResponseDto), + 401: DEFAULT_ERROR_RESPONSES[401], + 403: DEFAULT_ERROR_RESPONSES[403], + 404: DEFAULT_ERROR_RESPONSES[404], + 429: DEFAULT_ERROR_RESPONSES[429], + }, + schema: { + summary: 'Set Short URL Tags', + description: 'Replace all tags for a short URL with the provided tag IDs.', + operationId: 'tag/set-short-url-tags', + }, + }) + async setShortUrlTags( + request: IHttpRequest, + ): Promise> { + const { id: shortUrlId } = request.params; + const { tagIds } = request.body; + + const tags = await this.setShortUrlTagsUseCase.execute(shortUrlId, tagIds, request.user); + + return this.makeApiHttpResponse( + 200, + tags.map((t) => TagResponseDto.parse(t)), + ); + } + private async fetchOwnedTag(id: string, userId: string): Promise { const tag = await this.tagRepository.findOneById(id); if (!tag) throw new TagNotFoundError(); diff --git a/apps/backend/src/modules/tag/policies/__tests__/set-qr-code-tags.policy.test.ts b/apps/backend/src/modules/tag/policies/__tests__/set-qr-code-tags.policy.test.ts index aac647b6..662a9f78 100644 --- a/apps/backend/src/modules/tag/policies/__tests__/set-qr-code-tags.policy.test.ts +++ b/apps/backend/src/modules/tag/policies/__tests__/set-qr-code-tags.policy.test.ts @@ -1,5 +1,5 @@ import { SetQrCodeTagsPolicy } from '../set-qr-code-tags.policy'; -import { PlanLimitExceededError } from '@/core/error/http/plan-limit-exceeded.error'; +import { MaxTagsExceededError } from '@/modules/tag/error/http/max-tags-exceeded.error'; import { UnauthorizedError } from '@/core/error/http'; import type { TUser } from '@/core/domain/schema/UserSchema'; @@ -20,24 +20,26 @@ describe('SetQrCodeTagsPolicy', () => { expect(() => policy.checkAccess()).toThrow(UnauthorizedError); }); - it('should allow free user to set exactly 1 tag', () => { - const policy = new SetQrCodeTagsPolicy(freeUser, 1); + it('should allow any user to set up to 3 tags', () => { + const policy = new SetQrCodeTagsPolicy(freeUser, 3); expect(policy.checkAccess()).toBe(true); }); - it('should throw PlanLimitExceededError when free user tries to set > 1 tag', () => { - const policy = new SetQrCodeTagsPolicy(freeUser, 2); - expect(() => policy.checkAccess()).toThrow(PlanLimitExceededError); + it('should throw MaxTagsExceededError when any user tries to set > 3 tags', () => { + const policy = new SetQrCodeTagsPolicy(freeUser, 4); + expect(() => policy.checkAccess()).toThrow(MaxTagsExceededError); }); - it('should allow pro user to set up to 3 tags', () => { - const policy = new SetQrCodeTagsPolicy(proUser, 3); - expect(policy.checkAccess()).toBe(true); - }); + it('should apply the same limit regardless of plan', () => { + const freePolicy = new SetQrCodeTagsPolicy(freeUser, 3); + const proPolicy = new SetQrCodeTagsPolicy(proUser, 3); + expect(freePolicy.checkAccess()).toBe(true); + expect(proPolicy.checkAccess()).toBe(true); - it('should throw PlanLimitExceededError when pro user tries to set > 3 tags', () => { - const policy = new SetQrCodeTagsPolicy(proUser, 4); - expect(() => policy.checkAccess()).toThrow(PlanLimitExceededError); + const freePolicyOver = new SetQrCodeTagsPolicy(freeUser, 4); + const proPolicyOver = new SetQrCodeTagsPolicy(proUser, 4); + expect(() => freePolicyOver.checkAccess()).toThrow(MaxTagsExceededError); + expect(() => proPolicyOver.checkAccess()).toThrow(MaxTagsExceededError); }); it('should allow setting 0 tags (clearing all tags)', () => { @@ -45,10 +47,9 @@ describe('SetQrCodeTagsPolicy', () => { expect(policy.checkAccess()).toBe(true); }); - it('should default to free plan limits when user.plan is undefined', () => { - const userNoPlan: TUser = { id: 'user_789' } as TUser; - const policy = new SetQrCodeTagsPolicy(userNoPlan, 2); - expect(() => policy.checkAccess()).toThrow(PlanLimitExceededError); + it('should include a descriptive error message', () => { + const policy = new SetQrCodeTagsPolicy(freeUser, 4); + expect(() => policy.checkAccess()).toThrow('You can add a maximum of 3 tags.'); }); }); }); diff --git a/apps/backend/src/modules/tag/policies/set-qr-code-tags.policy.ts b/apps/backend/src/modules/tag/policies/set-qr-code-tags.policy.ts index 0e6959cc..267e6fe5 100644 --- a/apps/backend/src/modules/tag/policies/set-qr-code-tags.policy.ts +++ b/apps/backend/src/modules/tag/policies/set-qr-code-tags.policy.ts @@ -1,12 +1,12 @@ -import { TAGS_PER_QR_CODE_PLAN_LIMITS, type PlanName } from '@/core/config/plan.config'; import { type TUser } from '@/core/domain/schema/UserSchema'; import { UnauthorizedError } from '@/core/error/http'; -import { PlanLimitExceededError } from '@/core/error/http/plan-limit-exceeded.error'; +import { + MAX_TAGS_PER_RESOURCE, + MaxTagsExceededError, +} from '@/modules/tag/error/http/max-tags-exceeded.error'; import { AbstractPolicy } from '@/core/policies/abstract.policy'; export class SetQrCodeTagsPolicy extends AbstractPolicy { - private limits: Record = TAGS_PER_QR_CODE_PLAN_LIMITS; - constructor( private readonly user: TUser | undefined, private readonly requestedTagCount: number, @@ -19,9 +19,8 @@ export class SetQrCodeTagsPolicy extends AbstractPolicy { throw new UnauthorizedError('You need to be logged in to manage tags.'); } - const limit = this.limits[this.user.plan ?? 'free']; - if (this.requestedTagCount > limit) { - throw new PlanLimitExceededError('tags per QR code', limit); + if (this.requestedTagCount > MAX_TAGS_PER_RESOURCE) { + throw new MaxTagsExceededError(); } return true; diff --git a/apps/backend/src/modules/tag/policies/set-short-url-tags.policy.ts b/apps/backend/src/modules/tag/policies/set-short-url-tags.policy.ts new file mode 100644 index 00000000..3380a860 --- /dev/null +++ b/apps/backend/src/modules/tag/policies/set-short-url-tags.policy.ts @@ -0,0 +1,28 @@ +import { type TUser } from '@/core/domain/schema/UserSchema'; +import { UnauthorizedError } from '@/core/error/http'; +import { + MAX_TAGS_PER_RESOURCE, + MaxTagsExceededError, +} from '@/modules/tag/error/http/max-tags-exceeded.error'; +import { AbstractPolicy } from '@/core/policies/abstract.policy'; + +export class SetShortUrlTagsPolicy extends AbstractPolicy { + constructor( + private readonly user: TUser | undefined, + private readonly requestedTagCount: number, + ) { + super(); + } + + checkAccess(): true { + if (!this.user) { + throw new UnauthorizedError('You need to be logged in to manage tags.'); + } + + if (this.requestedTagCount > MAX_TAGS_PER_RESOURCE) { + throw new MaxTagsExceededError(); + } + + return true; + } +} diff --git a/apps/backend/src/modules/tag/useCase/set-short-url-tags.use-case.ts b/apps/backend/src/modules/tag/useCase/set-short-url-tags.use-case.ts new file mode 100644 index 00000000..1afa4da3 --- /dev/null +++ b/apps/backend/src/modules/tag/useCase/set-short-url-tags.use-case.ts @@ -0,0 +1,56 @@ +import { IBaseUseCase } from '@/core/interface/base-use-case.interface'; +import { inject, injectable } from 'tsyringe'; +import TagRepository from '../domain/repository/tag.repository'; +import { Logger } from '@/core/logging'; +import { type TTag } from '../domain/entities/tag.entity'; +import { type TUser } from '@/core/domain/schema/UserSchema'; +import ShortUrlRepository from '@/modules/url-shortener/domain/repository/short-url.repository'; +import { ShortUrlNotFoundError } from '@/modules/url-shortener/error/http/short-url-not-found.error'; +import { BadRequestError, ForbiddenError } from '@/core/error/http'; +import { SetShortUrlTagsPolicy } from '../policies/set-short-url-tags.policy'; + +@injectable() +export class SetShortUrlTagsUseCase implements IBaseUseCase { + constructor( + @inject(TagRepository) private tagRepository: TagRepository, + @inject(ShortUrlRepository) private shortUrlRepository: ShortUrlRepository, + @inject(Logger) private logger: Logger, + ) {} + + async execute(shortUrlId: string, tagIds: string[], user: TUser): Promise { + // Verify short URL exists and belongs to the authenticated user + const shortUrl = await this.shortUrlRepository.findOneById(shortUrlId); + if (!shortUrl) throw new ShortUrlNotFoundError(); + if (shortUrl.createdBy !== user.id) throw new ForbiddenError(); + + // Only standalone short URLs (not linked to a QR code) can have tags + if (shortUrl.qrCodeId != null) { + throw new BadRequestError( + 'Cannot set tags on a short URL linked to a QR code. Use QR code tags instead.', + ); + } + + // Verify all tags exist and belong to the authenticated user + for (const tagId of tagIds) { + const tag = await this.tagRepository.findOneById(tagId); + if (!tag || tag.createdBy !== user.id) throw new ForbiddenError(); + } + + // Check plan limits + const policy = new SetShortUrlTagsPolicy(user, tagIds.length); + policy.checkAccess(); + + await this.tagRepository.setShortUrlTags(shortUrlId, tagIds); + const tags = await this.tagRepository.findTagsByShortUrlId(shortUrlId); + + this.logger.info('tag.short-url-tags-set', { + shortUrlTags: { + shortUrlId, + tagCount: tagIds.length, + userId: user.id, + }, + }); + + return tags; + } +} diff --git a/apps/backend/src/modules/url-shortener/domain/entities/short-url-tag.entity.ts b/apps/backend/src/modules/url-shortener/domain/entities/short-url-tag.entity.ts new file mode 100644 index 00000000..406fc37c --- /dev/null +++ b/apps/backend/src/modules/url-shortener/domain/entities/short-url-tag.entity.ts @@ -0,0 +1,35 @@ +import { createTable } from '@/core/db/utils'; +import { relations } from 'drizzle-orm'; +import { index, primaryKey, varchar } from 'drizzle-orm/mysql-core'; +import tag from '@/modules/tag/domain/entities/tag.entity'; +import shortUrl from './short-url.entity'; + +const shortUrlTag = createTable( + 'short_url_tag', + { + shortUrlId: varchar('short_url_id', { length: 36 }) + .notNull() + .references(() => shortUrl.id, { onDelete: 'cascade' }), + tagId: varchar('tag_id', { length: 36 }) + .notNull() + .references(() => tag.id, { onDelete: 'cascade' }), + }, + (t) => [ + primaryKey({ columns: [t.shortUrlId, t.tagId] }), + index('i_short_url_tag_tag_id').on(t.tagId), + ], +); + +export type TShortUrlTag = typeof shortUrlTag.$inferSelect; +export default shortUrlTag; + +export const shortUrlTagRelations = relations(shortUrlTag, ({ one }) => ({ + shortUrl: one(shortUrl, { + fields: [shortUrlTag.shortUrlId], + references: [shortUrl.id], + }), + tag: one(tag, { + fields: [shortUrlTag.tagId], + references: [tag.id], + }), +})); diff --git a/apps/backend/src/modules/url-shortener/domain/entities/short-url.entity.ts b/apps/backend/src/modules/url-shortener/domain/entities/short-url.entity.ts index 33a7a3dc..3b8333f8 100644 --- a/apps/backend/src/modules/url-shortener/domain/entities/short-url.entity.ts +++ b/apps/backend/src/modules/url-shortener/domain/entities/short-url.entity.ts @@ -2,9 +2,11 @@ import { qrCode } from '@/core/db/schemas'; import customDomain, { type TCustomDomain, } from '@/modules/custom-domain/domain/entities/custom-domain.entity'; +import { type TTag } from '@/modules/tag/domain/entities/tag.entity'; import { createTable } from '@/core/db/utils'; import { relations } from 'drizzle-orm'; import { boolean, datetime, index, text, varchar } from 'drizzle-orm/mysql-core'; +import shortUrlTag from './short-url-tag.entity'; const shortUrl = createTable( 'short_url', @@ -13,6 +15,7 @@ const shortUrl = createTable( length: 36, }).primaryKey(), shortCode: varchar({ length: 5 }).notNull().unique(), + name: varchar({ length: 255 }), destinationUrl: text(), qrCodeId: varchar({ length: 36, @@ -44,10 +47,14 @@ export type TShortUrl = typeof shortUrl.$inferSelect; export type TShortUrlWithDomain = TShortUrl & { customDomain: TCustomDomain | null; }; +// Extended type that includes custom domain and tags +export type TShortUrlWithDomainAndTags = TShortUrlWithDomain & { + tags: TTag[]; +}; export default shortUrl; // Relation Definition for shortUrl -export const shortUrlRelations = relations(shortUrl, ({ one }) => ({ +export const shortUrlRelations = relations(shortUrl, ({ one, many }) => ({ qrCode: one(qrCode, { fields: [shortUrl.qrCodeId], references: [qrCode.id], @@ -56,4 +63,5 @@ export const shortUrlRelations = relations(shortUrl, ({ one }) => ({ fields: [shortUrl.customDomainId], references: [customDomain.id], }), + shortUrlTags: many(shortUrlTag), })); diff --git a/apps/backend/src/modules/url-shortener/domain/repository/short-url.repository.ts b/apps/backend/src/modules/url-shortener/domain/repository/short-url.repository.ts index aedb5ac0..86c078c9 100644 --- a/apps/backend/src/modules/url-shortener/domain/repository/short-url.repository.ts +++ b/apps/backend/src/modules/url-shortener/domain/repository/short-url.repository.ts @@ -1,8 +1,10 @@ import { singleton } from 'tsyringe'; -import { desc, eq } from 'drizzle-orm'; +import { and, desc, eq, inArray, isNull, isNotNull, sql, SQL } from 'drizzle-orm'; import AbstractRepository from '@/core/domain/repository/abstract.repository'; -import { type ISqlQueryFindBy } from '@/core/interface/repository.interface'; +import { type ISqlQueryFindBy, type WhereConditions } from '@/core/interface/repository.interface'; import shortUrl, { TShortUrl, TShortUrlWithDomain } from '../entities/short-url.entity'; +import { convertWhereConditionToDrizzle } from '@/core/db/utils'; +import shortUrlTag from '../entities/short-url-tag.entity'; /** * Repository for managing Short URL entities. @@ -15,6 +17,58 @@ class ShortUrlRepository extends AbstractRepository { super(); } + private tagIdsCondition(tagIds: string[]): SQL { + return inArray( + this.table.id, + this.db + .select({ shortUrlId: shortUrlTag.shortUrlId }) + .from(shortUrlTag) + .where(inArray(shortUrlTag.tagId, tagIds)), + ); + } + + /** + * Builds SQL conditions for filtering short URLs. + * Splits search fields (shortCode, destinationUrl) into OR conditions, + * and remaining fields into AND conditions. + */ + private buildFilterConditions( + where?: WhereConditions | SQL, + standalone?: boolean, + tagIds?: string[], + ): SQL[] { + const conditions: SQL[] = [isNull(this.table.deletedAt)]; + + if (where && !(where instanceof SQL)) { + const { shortCode, destinationUrl, ...rest } = where; + const searchWhere: WhereConditions = { + ...(shortCode && { shortCode }), + ...(destinationUrl && { destinationUrl }), + }; + const searchSql = Object.keys(searchWhere).length + ? convertWhereConditionToDrizzle(searchWhere, this.table, 'or') + : undefined; + const restSql = Object.keys(rest).length + ? convertWhereConditionToDrizzle(rest as WhereConditions, this.table) + : undefined; + if (searchSql) conditions.push(searchSql); + if (restSql) conditions.push(restSql); + } else if (where instanceof SQL) { + conditions.push(where); + } + + if (standalone) { + conditions.push(isNull(this.table.qrCodeId)); + conditions.push(isNotNull(this.table.destinationUrl)); + } + + if (tagIds?.length) { + conditions.push(this.tagIdsCondition(tagIds)); + } + + return conditions; + } + /** * Finds all Short URLs based on the provided query parameters. * @param options - Query options. @@ -77,6 +131,56 @@ class ShortUrlRepository extends AbstractRepository { return result; } + /** + * Finds all Short URLs with custom domain, supporting standalone filter. + * @param options - Query options including standalone flag. + * @returns A promise that resolves to an array of Short URLs with domain info. + */ + async findAllWithDomain({ + limit, + page, + where, + standalone, + tagIds, + }: ISqlQueryFindBy & { standalone?: boolean; tagIds?: string[] }): Promise< + TShortUrlWithDomain[] + > { + const conditions = this.buildFilterConditions(where, standalone, tagIds); + + const safePage = Math.max(0, (page || 1) - 1); + const results = await this.db.query.shortUrl.findMany({ + where: and(...conditions), + with: { customDomain: true }, + orderBy: [desc(this.table.createdAt)], + limit: limit || 10, + offset: safePage * (limit || 10), + }); + + return results as TShortUrlWithDomain[]; + } + + /** + * Counts total short URLs matching the given filters. + * @param where - Where conditions. + * @param standalone - If true, only count standalone short URLs. + * @returns The count of matching short URLs. + */ + async countTotalFiltered( + where?: WhereConditions, + standalone?: boolean, + tagIds?: string[], + ): Promise { + const conditions = this.buildFilterConditions(where, standalone, tagIds); + + const result = await this.db + .select({ count: sql`count(${this.table.id})` }) + .from(this.table) + .where(and(...conditions)) + .execute(); + + return result[0]?.count || 0; + } + /** * Updates a Short URL with the provided updates. * @param shortUrl - The Short URL to update. @@ -106,10 +210,12 @@ class ShortUrlRepository extends AbstractRepository { .insert(this.table) .values({ id: shortUrl.id, + name: shortUrl.name, destinationUrl: shortUrl.destinationUrl, shortCode: shortUrl.shortCode, isActive: shortUrl.isActive, customDomainId: shortUrl.customDomainId, + qrCodeId: shortUrl.qrCodeId, createdAt: new Date(), createdBy: shortUrl.createdBy, }) diff --git a/apps/backend/src/modules/url-shortener/http/__tests__/create-short-url.test.ts b/apps/backend/src/modules/url-shortener/http/__tests__/create-short-url.test.ts new file mode 100644 index 00000000..b5a39c4f --- /dev/null +++ b/apps/backend/src/modules/url-shortener/http/__tests__/create-short-url.test.ts @@ -0,0 +1,123 @@ +import { getTestContext } from '@/tests/shared/test-context'; +import { generateShortUrlDto } from '@/tests/shared/factories/short-url.factory'; +import type { FastifyInstance } from 'fastify'; +import type { TShortUrlWithCustomDomainResponseDto } from '@shared/schemas'; +import { SHORT_URL_API_PATH } from './utils'; + +describe('createShortUrl', () => { + let testServer: FastifyInstance; + let accessToken: string; + let userId: string; + + beforeAll(async () => { + const ctx = await getTestContext(); + testServer = ctx.testServer; + accessToken = ctx.accessToken; + userId = ctx.user.id; + }); + + const createShortUrlRequest = async (payload: object, token: string) => + testServer.inject({ + method: 'POST', + url: SHORT_URL_API_PATH, + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + payload, + }); + + it('should create a standalone short URL and return 201', async () => { + const dto = generateShortUrlDto(); + const response = await createShortUrlRequest(dto, accessToken); + expect(response.statusCode).toBe(201); + + const shortUrl = JSON.parse(response.payload) as TShortUrlWithCustomDomainResponseDto; + expect(shortUrl.id).toEqual(expect.any(String)); + expect(shortUrl.shortCode).toHaveLength(5); + expect(shortUrl.createdBy).toBe(userId); + expect(shortUrl.destinationUrl).toBe(dto.destinationUrl); + expect(shortUrl.isActive).toBe(true); + expect(shortUrl.qrCodeId).toBeNull(); + expect(shortUrl.tags).toEqual([]); + }); + + it('should generate a 5-character lowercase alphanumeric short code', async () => { + const dto = generateShortUrlDto(); + const response = await createShortUrlRequest(dto, accessToken); + const shortUrl = JSON.parse(response.payload) as TShortUrlWithCustomDomainResponseDto; + + expect(shortUrl.shortCode).toHaveLength(5); + expect(shortUrl.shortCode).toMatch(/^[a-z0-9]{5}$/); + }); + + it('should allow creating a short URL with isActive set to false', async () => { + const dto = generateShortUrlDto({ isActive: false }); + const response = await createShortUrlRequest(dto, accessToken); + expect(response.statusCode).toBe(201); + + const shortUrl = JSON.parse(response.payload) as TShortUrlWithCustomDomainResponseDto; + expect(shortUrl.isActive).toBe(false); + }); + + it('should default isActive to true when not provided', async () => { + const { isActive: _, ...dto } = generateShortUrlDto(); + const response = await createShortUrlRequest(dto, accessToken); + expect(response.statusCode).toBe(201); + + const shortUrl = JSON.parse(response.payload) as TShortUrlWithCustomDomainResponseDto; + expect(shortUrl.isActive).toBe(true); + }); + + it('should create a short URL with a name', async () => { + const dto = generateShortUrlDto({ name: 'My Campaign Link' }); + const response = await createShortUrlRequest(dto, accessToken); + expect(response.statusCode).toBe(201); + + const shortUrl = JSON.parse(response.payload) as TShortUrlWithCustomDomainResponseDto; + expect(shortUrl.name).toBe('My Campaign Link'); + }); + + it('should create a short URL without a name (null)', async () => { + const dto = generateShortUrlDto({ name: null }); + const response = await createShortUrlRequest(dto, accessToken); + expect(response.statusCode).toBe(201); + + const shortUrl = JSON.parse(response.payload) as TShortUrlWithCustomDomainResponseDto; + expect(shortUrl.name).toBeNull(); + }); + + it('should return 401 when not authenticated', async () => { + const response = await testServer.inject({ + method: 'POST', + url: SHORT_URL_API_PATH, + headers: { 'Content-Type': 'application/json' }, + payload: generateShortUrlDto(), + }); + expect(response.statusCode).toBe(401); + }); + + it('should return 400 for invalid destinationUrl format', async () => { + const response = await createShortUrlRequest( + { destinationUrl: 'not-a-valid-url', isActive: true, customDomainId: null }, + accessToken, + ); + expect(response.statusCode).toBe(400); + }); + + it('should return 400 when destinationUrl is missing', async () => { + const response = await createShortUrlRequest( + { isActive: true, customDomainId: null }, + accessToken, + ); + expect(response.statusCode).toBe(400); + }); + + it('should return 400 when destinationUrl is null', async () => { + const response = await createShortUrlRequest( + { destinationUrl: null, isActive: true, customDomainId: null }, + accessToken, + ); + expect(response.statusCode).toBe(400); + }); +}); diff --git a/apps/backend/src/modules/url-shortener/http/__tests__/delete-short-url.test.ts b/apps/backend/src/modules/url-shortener/http/__tests__/delete-short-url.test.ts new file mode 100644 index 00000000..272fb7fe --- /dev/null +++ b/apps/backend/src/modules/url-shortener/http/__tests__/delete-short-url.test.ts @@ -0,0 +1,99 @@ +import { getTestContext } from '@/tests/shared/test-context'; +import { generateEditableUrlQrCodeDto } from '@/modules/qr-code/http/__tests__/utils'; +import { API_BASE_PATH } from '@/core/config/constants'; +import type { FastifyInstance } from 'fastify'; +import type { TQrCodeWithRelationsResponseDto } from '@shared/schemas'; +import { SHORT_URL_API_PATH, createShortUrl } from './utils'; + +const QR_CODE_API_PATH = `${API_BASE_PATH}/qr-code`; + +describe('deleteShortUrl', () => { + let testServer: FastifyInstance; + let accessToken: string; + let accessToken2: string; + + beforeAll(async () => { + const ctx = await getTestContext(); + testServer = ctx.testServer; + accessToken = ctx.accessToken; + accessToken2 = ctx.accessToken2; + }); + + const deleteShortUrlRequest = async (shortCode: string, token: string) => + testServer.inject({ + method: 'DELETE', + url: `${SHORT_URL_API_PATH}/${shortCode}`, + headers: { Authorization: `Bearer ${token}` }, + }); + + it('should soft-delete a standalone short URL and return 200', async () => { + const shortUrl = await createShortUrl(testServer, accessToken); + + const response = await deleteShortUrlRequest(shortUrl.shortCode, accessToken); + expect(response.statusCode).toBe(200); + + const body = JSON.parse(response.payload); + expect(body.deleted).toBe(true); + }); + + it('should return 404 when fetching a deleted short URL', async () => { + const shortUrl = await createShortUrl(testServer, accessToken); + await deleteShortUrlRequest(shortUrl.shortCode, accessToken); + + const getResponse = await testServer.inject({ + method: 'GET', + url: `${SHORT_URL_API_PATH}/${shortUrl.shortCode}`, + }); + expect(getResponse.statusCode).toBe(404); + }); + + it('should return 400 when trying to delete a QR-code-linked short URL', async () => { + // Create a dynamic QR code which generates a linked short URL + const createResponse = await testServer.inject({ + method: 'POST', + url: QR_CODE_API_PATH, + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, + }, + payload: generateEditableUrlQrCodeDto(), + }); + expect(createResponse.statusCode).toBe(201); + const qrCode = JSON.parse(createResponse.payload) as TQrCodeWithRelationsResponseDto; + expect(qrCode.shortUrl).not.toBeNull(); + + const response = await deleteShortUrlRequest(qrCode.shortUrl!.shortCode, accessToken); + expect(response.statusCode).toBe(400); + + const error = JSON.parse(response.payload); + expect(error.message).toContain('linked to a QR code'); + }); + + it('should return 401 when not authenticated', async () => { + const response = await testServer.inject({ + method: 'DELETE', + url: `${SHORT_URL_API_PATH}/XXXXX`, + }); + expect(response.statusCode).toBe(401); + }); + + it("should return 403 when trying to delete another user's short URL", async () => { + const shortUrl = await createShortUrl(testServer, accessToken); + + const response = await deleteShortUrlRequest(shortUrl.shortCode, accessToken2); + expect(response.statusCode).toBe(403); + }); + + it('should return 404 when shortCode does not exist', async () => { + const response = await deleteShortUrlRequest('XXXXX', accessToken); + expect(response.statusCode).toBe(404); + }); + + it('should return 404 when trying to delete an already deleted short URL', async () => { + const shortUrl = await createShortUrl(testServer, accessToken); + await deleteShortUrlRequest(shortUrl.shortCode, accessToken); + + const response = await deleteShortUrlRequest(shortUrl.shortCode, accessToken); + expect(response.statusCode).toBe(404); + }); +}); diff --git a/apps/backend/src/modules/url-shortener/http/__tests__/get-short-url-analytics.test.ts b/apps/backend/src/modules/url-shortener/http/__tests__/get-short-url-analytics.test.ts index 730c60de..842b852c 100644 --- a/apps/backend/src/modules/url-shortener/http/__tests__/get-short-url-analytics.test.ts +++ b/apps/backend/src/modules/url-shortener/http/__tests__/get-short-url-analytics.test.ts @@ -2,6 +2,7 @@ import { getTestContext } from '@/tests/shared/test-context'; import type { FastifyInstance } from 'fastify'; import type { TShortUrlResponseDto, TAnalyticsResponseDto } from '@shared/schemas'; import { SHORT_URL_API_PATH, reserveShortUrl } from './utils'; +import { mockFetchUmamiAllEndpoints, resetFetchMocks } from '@/tests/shared/mocks/umami.mock'; describe('getShortUrlAnalytics', () => { let testServer: FastifyInstance; @@ -15,6 +16,14 @@ describe('getShortUrlAnalytics', () => { accessToken2 = ctx.accessToken2; }); + beforeEach(() => { + mockFetchUmamiAllEndpoints(); + }); + + afterEach(() => { + resetFetchMocks(); + }); + const getAnalyticsRequest = async (shortCode: string, token: string) => testServer.inject({ method: 'GET', diff --git a/apps/backend/src/modules/url-shortener/http/__tests__/get-short-url-views.test.ts b/apps/backend/src/modules/url-shortener/http/__tests__/get-short-url-views.test.ts index 0532afe7..d29828b0 100644 --- a/apps/backend/src/modules/url-shortener/http/__tests__/get-short-url-views.test.ts +++ b/apps/backend/src/modules/url-shortener/http/__tests__/get-short-url-views.test.ts @@ -2,6 +2,7 @@ import { getTestContext } from '@/tests/shared/test-context'; import type { FastifyInstance } from 'fastify'; import type { TShortUrlResponseDto } from '@shared/schemas'; import { SHORT_URL_API_PATH, reserveShortUrl } from './utils'; +import { mockFetchUmamiAllEndpoints, resetFetchMocks } from '@/tests/shared/mocks/umami.mock'; describe('getShortUrlViews', () => { let testServer: FastifyInstance; @@ -15,6 +16,14 @@ describe('getShortUrlViews', () => { accessToken2 = ctx.accessToken2; }); + beforeEach(() => { + mockFetchUmamiAllEndpoints(); + }); + + afterEach(() => { + resetFetchMocks(); + }); + const getViewsRequest = async (shortCode: string, token: string) => testServer.inject({ method: 'GET', diff --git a/apps/backend/src/modules/url-shortener/http/__tests__/list-short-urls.test.ts b/apps/backend/src/modules/url-shortener/http/__tests__/list-short-urls.test.ts new file mode 100644 index 00000000..452f19d4 --- /dev/null +++ b/apps/backend/src/modules/url-shortener/http/__tests__/list-short-urls.test.ts @@ -0,0 +1,318 @@ +import { getTestContext } from '@/tests/shared/test-context'; +import type { FastifyInstance } from 'fastify'; +import { + type TShortUrlWithCustomDomainPaginatedResponseDto, + QrCodeDefaults, +} from '@shared/schemas'; +import { SHORT_URL_API_PATH, createShortUrl } from './utils'; +import { API_BASE_PATH } from '@/core/config/constants'; +import qs from 'qs'; + +const TAG_API_PATH = `${API_BASE_PATH}/tag`; +const QR_CODE_API_PATH = `${API_BASE_PATH}/qr-code`; + +describe('listShortUrls', () => { + let testServer: FastifyInstance; + let accessToken: string; + let accessToken2: string; + let userId: string; + let user2Id: string; + + beforeAll(async () => { + const ctx = await getTestContext(); + testServer = ctx.testServer; + accessToken = ctx.accessToken; + accessToken2 = ctx.accessToken2; + userId = ctx.user.id; + user2Id = ctx.user2.id; + }); + + const listShortUrlsRequest = async (token: string, queryParams: Record = {}) => { + return testServer.inject({ + method: 'GET', + url: `${SHORT_URL_API_PATH}?${qs.stringify(queryParams)}`, + headers: { Authorization: `Bearer ${token}` }, + }); + }; + + const createTag = async (token: string, name: string) => { + const response = await testServer.inject({ + method: 'POST', + url: TAG_API_PATH, + headers: { Authorization: `Bearer ${token}` }, + payload: { name, color: '#FF5733' }, + }); + expect(response.statusCode).toBe(201); + return JSON.parse(response.payload); + }; + + const assignTagsToShortUrl = async (token: string, shortUrlId: string, tagIds: string[]) => { + const response = await testServer.inject({ + method: 'PUT', + url: `${TAG_API_PATH}/short-url/${shortUrlId}`, + headers: { Authorization: `Bearer ${token}` }, + payload: { tagIds }, + }); + expect(response.statusCode).toBe(200); + }; + + it('should return paginated list of standalone short URLs', async () => { + await createShortUrl(testServer, accessToken); + + const response = await listShortUrlsRequest(accessToken, { standalone: true }); + expect(response.statusCode).toBe(200); + + const data = JSON.parse(response.payload) as TShortUrlWithCustomDomainPaginatedResponseDto; + expect(data).toHaveProperty('page'); + expect(data).toHaveProperty('limit'); + expect(data).toHaveProperty('total'); + expect(data).toHaveProperty('data'); + expect(Array.isArray(data.data)).toBe(true); + expect(data.total).toBeGreaterThanOrEqual(1); + }); + + it('should only return short URLs owned by the authenticated user', async () => { + const user1ShortUrl = await createShortUrl(testServer, accessToken); + const user2ShortUrl = await createShortUrl(testServer, accessToken2); + + const response1 = await listShortUrlsRequest(accessToken, { standalone: true }); + const data1 = JSON.parse(response1.payload) as TShortUrlWithCustomDomainPaginatedResponseDto; + + const response2 = await listShortUrlsRequest(accessToken2, { standalone: true }); + const data2 = JSON.parse(response2.payload) as TShortUrlWithCustomDomainPaginatedResponseDto; + + // Each user should only see their own short URLs + expect(data1.data.every((su) => su.createdBy === userId)).toBe(true); + expect(data2.data.every((su) => su.createdBy === user2Id)).toBe(true); + + // Created short URL should only be visible to its owner + expect(data1.data.some((su) => su.id === user1ShortUrl.id)).toBe(true); + expect(data1.data.some((su) => su.id === user2ShortUrl.id)).toBe(false); + expect(data2.data.some((su) => su.id === user2ShortUrl.id)).toBe(true); + expect(data2.data.some((su) => su.id === user1ShortUrl.id)).toBe(false); + + // IDs should not overlap + const ids1 = new Set(data1.data.map((su) => su.id)); + const ids2 = new Set(data2.data.map((su) => su.id)); + const overlap = [...ids1].filter((id) => ids2.has(id)); + expect(overlap).toHaveLength(0); + }); + + it('should respect pagination parameters', async () => { + // Create enough short URLs to paginate + await createShortUrl(testServer, accessToken); + await createShortUrl(testServer, accessToken); + + const response = await listShortUrlsRequest(accessToken, { + standalone: true, + page: 1, + limit: 1, + }); + expect(response.statusCode).toBe(200); + + const data = JSON.parse(response.payload) as TShortUrlWithCustomDomainPaginatedResponseDto; + expect(data.data.length).toBeLessThanOrEqual(1); + expect(data.limit).toBe(1); + }); + + it('should filter by search term across shortCode and destinationUrl', async () => { + const shortUrl = await createShortUrl(testServer, accessToken); + + const response = await listShortUrlsRequest(accessToken, { + standalone: true, + where: { shortCode: { like: shortUrl.shortCode } }, + }); + expect(response.statusCode).toBe(200); + + const data = JSON.parse(response.payload) as TShortUrlWithCustomDomainPaginatedResponseDto; + expect(data.data.length).toBeGreaterThanOrEqual(1); + expect(data.data.some((su) => su.shortCode === shortUrl.shortCode)).toBe(true); + }); + + it('should return 401 when not authenticated', async () => { + const response = await testServer.inject({ + method: 'GET', + url: SHORT_URL_API_PATH, + }); + expect(response.statusCode).toBe(401); + }); + + it('should include tags and name in list response items', async () => { + const shortUrl = await createShortUrl(testServer, accessToken, { name: 'List Tag Test' }); + + const response = await listShortUrlsRequest(accessToken, { standalone: true }); + expect(response.statusCode).toBe(200); + + const data = JSON.parse(response.payload) as TShortUrlWithCustomDomainPaginatedResponseDto; + const found = data.data.find((su) => su.id === shortUrl.id); + expect(found).toBeDefined(); + expect(found!.name).toBe('List Tag Test'); + expect(found!.tags).toEqual(expect.any(Array)); + }); + + it('should not include soft-deleted short URLs', async () => { + const shortUrl = await createShortUrl(testServer, accessToken); + + // Delete it + const deleteResponse = await testServer.inject({ + method: 'DELETE', + url: `${SHORT_URL_API_PATH}/${shortUrl.shortCode}`, + headers: { Authorization: `Bearer ${accessToken}` }, + }); + expect(deleteResponse.statusCode).toBe(200); + + // List should not include the deleted short URL + const response = await listShortUrlsRequest(accessToken, { standalone: true }); + const data = JSON.parse(response.payload) as TShortUrlWithCustomDomainPaginatedResponseDto; + expect(data.data.find((su) => su.id === shortUrl.id)).toBeUndefined(); + }); + + describe('standalone filter (no QR code short URLs)', () => { + it('should not include short URLs linked to QR codes when standalone=true', async () => { + // Create a QR code with dynamic URL (which creates a linked short URL) + const qrResponse = await testServer.inject({ + method: 'POST', + url: QR_CODE_API_PATH, + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + payload: { + name: 'QR for standalone test', + content: { + type: 'url', + data: { url: 'https://example.com/standalone-test', isEditable: true }, + }, + config: QrCodeDefaults, + }, + }); + expect(qrResponse.statusCode).toBe(201); + + // List standalone short URLs - should NOT contain QR-linked ones + const response = await listShortUrlsRequest(accessToken, { standalone: true }); + const data = JSON.parse(response.payload) as TShortUrlWithCustomDomainPaginatedResponseDto; + + // Every returned short URL must have qrCodeId as null + for (const su of data.data) { + expect(su.qrCodeId).toBeNull(); + } + }); + }); + + describe('tagIds filter', () => { + it('should filter short URLs by tag ID', async () => { + const tag = await createTag(accessToken, `FilterTag ${Date.now()}`); + const shortUrl = await createShortUrl(testServer, accessToken, { + name: `Tagged SU ${Date.now()}`, + }); + await assignTagsToShortUrl(accessToken, shortUrl.id, [tag.id]); + + const response = await listShortUrlsRequest(accessToken, { + standalone: true, + tagIds: [tag.id], + }); + + expect(response.statusCode).toBe(200); + const data = JSON.parse(response.payload) as TShortUrlWithCustomDomainPaginatedResponseDto; + expect(data.total).toBe(1); + expect(data.data).toHaveLength(1); + expect(data.data[0].id).toBe(shortUrl.id); + }); + + it('should return empty results for tag with no short URLs', async () => { + const tag = await createTag(accessToken, `EmptyTag ${Date.now()}`); + + const response = await listShortUrlsRequest(accessToken, { + standalone: true, + tagIds: [tag.id], + }); + + expect(response.statusCode).toBe(200); + const data = JSON.parse(response.payload) as TShortUrlWithCustomDomainPaginatedResponseDto; + expect(data.total).toBe(0); + expect(data.data).toHaveLength(0); + }); + + it('should filter by multiple tag IDs (OR logic)', async () => { + const tag1 = await createTag(accessToken, `MultiTag1 ${Date.now()}`); + const tag2 = await createTag(accessToken, `MultiTag2 ${Date.now()}`); + const shortUrl1 = await createShortUrl(testServer, accessToken, { + name: `Multi1 ${Date.now()}`, + }); + const shortUrl2 = await createShortUrl(testServer, accessToken, { + name: `Multi2 ${Date.now()}`, + }); + await assignTagsToShortUrl(accessToken, shortUrl1.id, [tag1.id]); + await assignTagsToShortUrl(accessToken, shortUrl2.id, [tag2.id]); + + const response = await listShortUrlsRequest(accessToken, { + standalone: true, + tagIds: [tag1.id, tag2.id], + }); + + expect(response.statusCode).toBe(200); + const data = JSON.parse(response.payload) as TShortUrlWithCustomDomainPaginatedResponseDto; + expect(data.total).toBe(2); + const ids = data.data.map((su) => su.id); + expect(ids).toContain(shortUrl1.id); + expect(ids).toContain(shortUrl2.id); + }); + + it('should return correct total count when filtering by tags', async () => { + const tag = await createTag(accessToken, `CountTag ${Date.now()}`); + const shortUrl1 = await createShortUrl(testServer, accessToken); + const shortUrl2 = await createShortUrl(testServer, accessToken); + await createShortUrl(testServer, accessToken); // untagged + + await assignTagsToShortUrl(accessToken, shortUrl1.id, [tag.id]); + await assignTagsToShortUrl(accessToken, shortUrl2.id, [tag.id]); + + const response = await listShortUrlsRequest(accessToken, { + standalone: true, + tagIds: [tag.id], + }); + + expect(response.statusCode).toBe(200); + const data = JSON.parse(response.payload) as TShortUrlWithCustomDomainPaginatedResponseDto; + expect(data.total).toBe(2); + expect(data.data).toHaveLength(2); + }); + + it('should combine tag filter with search filter', async () => { + const uniqueName = `SearchTagCombo ${Date.now()}`; + const tag = await createTag(accessToken, `ComboTag ${Date.now()}`); + const shortUrl = await createShortUrl(testServer, accessToken, { name: uniqueName }); + await createShortUrl(testServer, accessToken); // no tag, different name + await assignTagsToShortUrl(accessToken, shortUrl.id, [tag.id]); + + const response = await listShortUrlsRequest(accessToken, { + standalone: true, + tagIds: [tag.id], + where: { shortCode: { like: shortUrl.shortCode } }, + }); + + expect(response.statusCode).toBe(200); + const data = JSON.parse(response.payload) as TShortUrlWithCustomDomainPaginatedResponseDto; + expect(data.total).toBe(1); + expect(data.data[0].id).toBe(shortUrl.id); + }); + + it('should include tags in filtered results', async () => { + const tag = await createTag(accessToken, `IncludeTag ${Date.now()}`); + const shortUrl = await createShortUrl(testServer, accessToken); + await assignTagsToShortUrl(accessToken, shortUrl.id, [tag.id]); + + const response = await listShortUrlsRequest(accessToken, { + standalone: true, + tagIds: [tag.id], + }); + + expect(response.statusCode).toBe(200); + const data = JSON.parse(response.payload) as TShortUrlWithCustomDomainPaginatedResponseDto; + const found = data.data.find((su) => su.id === shortUrl.id); + expect(found).toBeDefined(); + expect(found!.tags).toHaveLength(1); + expect(found!.tags[0].id).toBe(tag.id); + }); + }); +}); diff --git a/apps/backend/src/modules/url-shortener/http/__tests__/toggle-short-url-active-state.test.ts b/apps/backend/src/modules/url-shortener/http/__tests__/toggle-short-url-active-state.test.ts index 2db68c20..453b0965 100644 --- a/apps/backend/src/modules/url-shortener/http/__tests__/toggle-short-url-active-state.test.ts +++ b/apps/backend/src/modules/url-shortener/http/__tests__/toggle-short-url-active-state.test.ts @@ -1,9 +1,13 @@ import { getTestContext } from '@/tests/shared/test-context'; import { generateUpdateShortUrlDto } from '@/tests/shared/factories/short-url.factory'; +import { generateEditableUrlQrCodeDto } from '@/modules/qr-code/http/__tests__/utils'; +import { API_BASE_PATH } from '@/core/config/constants'; import type { FastifyInstance } from 'fastify'; -import type { TShortUrlResponseDto } from '@shared/schemas'; +import type { TQrCodeWithRelationsResponseDto, TShortUrlResponseDto } from '@shared/schemas'; import { SHORT_URL_API_PATH, reserveShortUrl } from './utils'; +const QR_CODE_API_PATH = `${API_BASE_PATH}/qr-code`; + describe('toggleShortUrlActiveState', () => { let testServer: FastifyInstance; let accessToken: string; @@ -94,4 +98,26 @@ describe('toggleShortUrlActiveState', () => { const response = await toggleActiveStateRequest('XXXXX', accessToken); expect(response.statusCode).toBe(404); }); + + it('should allow toggling active state of a QR-code-linked short URL', async () => { + const createResponse = await testServer.inject({ + method: 'POST', + url: QR_CODE_API_PATH, + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, + }, + payload: generateEditableUrlQrCodeDto(), + }); + expect(createResponse.statusCode).toBe(201); + const qrCode = JSON.parse(createResponse.payload) as TQrCodeWithRelationsResponseDto; + expect(qrCode.shortUrl).not.toBeNull(); + + const initialActive = qrCode.shortUrl!.isActive; + const response = await toggleActiveStateRequest(qrCode.shortUrl!.shortCode, accessToken); + expect(response.statusCode).toBe(200); + + const toggled = JSON.parse(response.payload) as TShortUrlResponseDto; + expect(toggled.isActive).toBe(!initialActive); + }); }); diff --git a/apps/backend/src/modules/url-shortener/http/__tests__/update-short-url.test.ts b/apps/backend/src/modules/url-shortener/http/__tests__/update-short-url.test.ts index ea6a4d31..17283466 100644 --- a/apps/backend/src/modules/url-shortener/http/__tests__/update-short-url.test.ts +++ b/apps/backend/src/modules/url-shortener/http/__tests__/update-short-url.test.ts @@ -1,8 +1,17 @@ import { getTestContext } from '@/tests/shared/test-context'; import { generateUpdateShortUrlDto } from '@/tests/shared/factories/short-url.factory'; +import { generateEditableUrlQrCodeDto } from '@/modules/qr-code/http/__tests__/utils'; +import { API_BASE_PATH } from '@/core/config/constants'; import type { FastifyInstance } from 'fastify'; -import type { TShortUrlResponseDto } from '@shared/schemas'; +import type { + TQrCodeWithRelationsResponseDto, + TShortUrlResponseDto, + TShortUrlWithCustomDomainResponseDto, +} from '@shared/schemas'; import { SHORT_URL_API_PATH, reserveShortUrl } from './utils'; +import { env } from '@/core/config/env'; + +const QR_CODE_API_PATH = `${API_BASE_PATH}/qr-code`; describe('updateShortUrl', () => { let testServer: FastifyInstance; @@ -109,7 +118,7 @@ describe('updateShortUrl', () => { const reserveResponse = await reserveShortUrl(testServer, accessToken); const shortUrl = JSON.parse(reserveResponse.payload) as TShortUrlResponseDto; - const selfReferencingUrl = `http://localhost:3000/u/${shortUrl.shortCode}`; + const selfReferencingUrl = `${env.FRONTEND_URL}/u/${shortUrl.shortCode}`; const response = await updateShortUrlRequest( shortUrl.shortCode, @@ -136,4 +145,110 @@ describe('updateShortUrl', () => { const updated = JSON.parse(response.payload) as TShortUrlResponseDto; expect(updated.destinationUrl).toBe('https://completely-different-site.com'); }); + + it('should return 400 when destinationUrl is set to null', async () => { + const reserveResponse = await reserveShortUrl(testServer, accessToken); + const shortUrl = JSON.parse(reserveResponse.payload) as TShortUrlResponseDto; + + // First set a valid destination URL + await updateShortUrlRequest( + shortUrl.shortCode, + { destinationUrl: 'https://example.com' }, + accessToken, + ); + + // Try to set it to null + const response = await updateShortUrlRequest( + shortUrl.shortCode, + { destinationUrl: null }, + accessToken, + ); + expect(response.statusCode).toBe(400); + }); + + it('should return 400 when destinationUrl is set to empty string', async () => { + const reserveResponse = await reserveShortUrl(testServer, accessToken); + const shortUrl = JSON.parse(reserveResponse.payload) as TShortUrlResponseDto; + + const response = await updateShortUrlRequest( + shortUrl.shortCode, + { destinationUrl: '' }, + accessToken, + ); + expect(response.statusCode).toBe(400); + }); + + it('should ignore customDomainId when sent in update payload', async () => { + const reserveResponse = await reserveShortUrl(testServer, accessToken); + const shortUrl = JSON.parse(reserveResponse.payload) as TShortUrlResponseDto; + + const response = await updateShortUrlRequest( + shortUrl.shortCode, + { + destinationUrl: 'https://example.com', + customDomainId: '00000000-0000-0000-0000-000000000000', + }, + accessToken, + ); + + // Should succeed but strip customDomainId (Zod strips unknown keys) + expect(response.statusCode).toBe(200); + const updated = JSON.parse(response.payload) as TShortUrlWithCustomDomainResponseDto; + expect(updated.customDomain).toBeNull(); + }); + + it('should update name successfully', async () => { + const reserveResponse = await reserveShortUrl(testServer, accessToken); + const shortUrl = JSON.parse(reserveResponse.payload) as TShortUrlResponseDto; + + const response = await updateShortUrlRequest( + shortUrl.shortCode, + { name: 'My Updated Link' }, + accessToken, + ); + expect(response.statusCode).toBe(200); + + const updated = JSON.parse(response.payload) as TShortUrlWithCustomDomainResponseDto; + expect(updated.name).toBe('My Updated Link'); + }); + + it('should set name to null when updating with null', async () => { + const reserveResponse = await reserveShortUrl(testServer, accessToken); + const shortUrl = JSON.parse(reserveResponse.payload) as TShortUrlResponseDto; + + // First set a name + await updateShortUrlRequest(shortUrl.shortCode, { name: 'Test Name' }, accessToken); + + // Then clear it + const response = await updateShortUrlRequest(shortUrl.shortCode, { name: null }, accessToken); + expect(response.statusCode).toBe(200); + + const updated = JSON.parse(response.payload) as TShortUrlWithCustomDomainResponseDto; + expect(updated.name).toBeNull(); + }); + + it('should return 400 when trying to update a QR-code-linked short URL', async () => { + const createResponse = await testServer.inject({ + method: 'POST', + url: QR_CODE_API_PATH, + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, + }, + payload: generateEditableUrlQrCodeDto(), + }); + expect(createResponse.statusCode).toBe(201); + const qrCode = JSON.parse(createResponse.payload) as TQrCodeWithRelationsResponseDto; + expect(qrCode.shortUrl).not.toBeNull(); + + const response = await updateShortUrlRequest( + qrCode.shortUrl!.shortCode, + { destinationUrl: 'https://new-destination.com' }, + accessToken, + ); + expect(response.statusCode).toBe(400); + + const error = JSON.parse(response.payload); + expect(error.message).toContain('linked to a QR code'); + }); }); diff --git a/apps/backend/src/modules/url-shortener/http/__tests__/utils.ts b/apps/backend/src/modules/url-shortener/http/__tests__/utils.ts index 51cb32da..4c4cd8dd 100644 --- a/apps/backend/src/modules/url-shortener/http/__tests__/utils.ts +++ b/apps/backend/src/modules/url-shortener/http/__tests__/utils.ts @@ -1,5 +1,7 @@ import { API_BASE_PATH } from '@/core/config/constants'; import type { FastifyInstance } from 'fastify'; +import type { TShortUrlWithCustomDomainResponseDto } from '@shared/schemas'; +import { generateShortUrlDto } from '@/tests/shared/factories/short-url.factory'; export const SHORT_URL_API_PATH = `${API_BASE_PATH}/short-url`; @@ -9,3 +11,21 @@ export const reserveShortUrl = (testServer: FastifyInstance, token: string) => url: `${SHORT_URL_API_PATH}/reserved`, headers: { Authorization: `Bearer ${token}` }, }); + +export const createShortUrl = async ( + testServer: FastifyInstance, + token: string, + overrides?: Parameters[0], +): Promise => { + const response = await testServer.inject({ + method: 'POST', + url: SHORT_URL_API_PATH, + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + payload: generateShortUrlDto(overrides), + }); + expect(response.statusCode).toBe(201); + return JSON.parse(response.payload) as TShortUrlWithCustomDomainResponseDto; +}; diff --git a/apps/backend/src/modules/url-shortener/http/controller/short-url.controller.ts b/apps/backend/src/modules/url-shortener/http/controller/short-url.controller.ts index 6832e085..d6f8bdb6 100644 --- a/apps/backend/src/modules/url-shortener/http/controller/short-url.controller.ts +++ b/apps/backend/src/modules/url-shortener/http/controller/short-url.controller.ts @@ -1,15 +1,22 @@ -import { Get, Patch, Post } from '@/core/decorators/route'; +import { Delete, Get, Patch, Post } from '@/core/decorators/route'; import AbstractController from '@/core/http/controller/abstract.controller'; import { type IHttpRequest } from '@/core/interface/request.interface'; import { inject, injectable } from 'tsyringe'; import ShortUrlRepository from '../../domain/repository/short-url.repository'; import { type IHttpResponse } from '@/core/interface/response.interface'; import { ShortUrlNotFoundError } from '../../error/http/short-url-not-found.error'; +import { BadRequestError } from '@/core/error/http'; import { AnalyticsResponseDto, + CreateShortUrlDto, + GetShortUrlQueryParamsSchema, + ShortUrlWithCustomDomainPaginatedResponseDto, ShortUrlWithCustomDomainResponseDto, TAnalyticsResponseDto, + TCreateShortUrlDto, + TGetShortUrlQueryParamsDto, TGetShortUrlRequestQueryDto, + TShortUrlWithCustomDomainPaginatedResponseDto, TShortUrlWithCustomDomainResponseDto, TTrackScanDto, TUpdateShortUrlDto, @@ -19,11 +26,16 @@ import { import { GetReservedShortCodeUseCase } from '../../useCase/get-reserved-short-url.use-case'; import { UmamiAnalyticsService } from '../../service/umami-analytics.service'; import { UpdateShortUrlUseCase } from '../../useCase/update-short-url.use-case'; +import { CreateShortUrlUseCase } from '../../useCase/create-short-url.use-case'; +import { ListShortUrlsUseCase } from '../../useCase/list-short-urls.use-case'; +import { DeleteShortUrlUseCase } from '../../useCase/delete-short-url.use-case'; import { TShortUrl } from '../../domain/entities/short-url.entity'; import { DEFAULT_ERROR_RESPONSES } from '@/core/error/http/error.schemas'; +import { DeleteResponseSchema } from '@/core/domain/schema/DeleteResponseSchema'; import { KeyCache } from '@/core/cache'; import { internalApiAuthHandler } from '@/core/http/middleware/internal-api-auth.middleware'; import { DispatchTrackingEventUseCase } from '@/modules/analytics-integration/useCase/dispatch-tracking-event.use-case'; +import TagRepository from '@/modules/tag/domain/repository/tag.repository'; @injectable() export class ShortUrlController extends AbstractController { @@ -33,10 +45,17 @@ export class ShortUrlController extends AbstractController { private readonly getReservedShortCodeUseCase: GetReservedShortCodeUseCase, @inject(UpdateShortUrlUseCase) private readonly updateShortUrlUseCase: UpdateShortUrlUseCase, + @inject(CreateShortUrlUseCase) + private readonly createShortUrlUseCase: CreateShortUrlUseCase, + @inject(ListShortUrlsUseCase) + private readonly listShortUrlsUseCase: ListShortUrlsUseCase, + @inject(DeleteShortUrlUseCase) + private readonly deleteShortUrlUseCase: DeleteShortUrlUseCase, @inject(UmamiAnalyticsService) private readonly umamiAnalyticsService: UmamiAnalyticsService, @inject(KeyCache) private readonly keyCache: KeyCache, @inject(DispatchTrackingEventUseCase) private readonly dispatchTrackingEventUseCase: DispatchTrackingEventUseCase, + @inject(TagRepository) private readonly tagRepository: TagRepository, ) { super(); } @@ -45,6 +64,119 @@ export class ShortUrlController extends AbstractController { return `views:${shortCode}`; } + @Get('', { + querySchema: GetShortUrlQueryParamsSchema, + responseSchema: { + 200: ShortUrlWithCustomDomainPaginatedResponseDto, + 400: DEFAULT_ERROR_RESPONSES[400], + 401: DEFAULT_ERROR_RESPONSES[401], + 429: DEFAULT_ERROR_RESPONSES[429], + }, + schema: { + summary: 'List short URLs', + description: + "Lists the authenticated user's short URLs with pagination and optional filtering. Use standalone=true to only show standalone short URLs (not linked to QR codes).", + operationId: 'short-url/list-short-urls', + }, + }) + async list( + request: IHttpRequest, + ): Promise> { + const { page, limit, where, standalone, tagIds } = request.query; + const { shortUrls, total } = await this.listShortUrlsUseCase.execute( + { limit, page, where, standalone, tagIds }, + request.user.id, + ); + + const pagination = { + page, + limit, + total, + data: shortUrls, + }; + + return this.makeApiHttpResponse( + 200, + ShortUrlWithCustomDomainPaginatedResponseDto.parse(pagination), + ); + } + + @Post('', { + bodySchema: CreateShortUrlDto, + responseSchema: { + 201: ShortUrlWithCustomDomainResponseDto, + 400: DEFAULT_ERROR_RESPONSES[400], + 401: DEFAULT_ERROR_RESPONSES[401], + 429: DEFAULT_ERROR_RESPONSES[429], + }, + schema: { + summary: 'Create a standalone short URL', + description: + 'Creates a new standalone short URL (not linked to a QR code). Requires a destination URL. Returns the created short URL object.', + operationId: 'short-url/create-short-url', + }, + }) + async create( + request: IHttpRequest, + ): Promise> { + const shortUrl = await this.createShortUrlUseCase.execute(request.body, request.user.id); + + return this.makeApiHttpResponse( + 201, + ShortUrlWithCustomDomainResponseDto.parse({ ...shortUrl, tags: [] }), + ); + } + + @Delete('/:shortCode', { + responseSchema: { + 200: DeleteResponseSchema, + 400: DEFAULT_ERROR_RESPONSES[400], + 401: DEFAULT_ERROR_RESPONSES[401], + 403: DEFAULT_ERROR_RESPONSES[403], + 404: DEFAULT_ERROR_RESPONSES[404], + 429: DEFAULT_ERROR_RESPONSES[429], + }, + schema: { + summary: 'Delete a standalone short URL', + description: + 'Soft-deletes a standalone short URL. Only standalone short URLs (not linked to QR codes) can be deleted via this endpoint.', + operationId: 'short-url/delete-short-url', + }, + }) + async deleteShortUrl( + request: IHttpRequest, + ): Promise> { + const shortUrl = await this.fetchShortUrl(request.params.shortCode, request.user.id); + await this.deleteShortUrlUseCase.execute(shortUrl, request.user.id); + return this.makeApiHttpResponse(200, { deleted: true }); + } + + @Get('/:shortCode/detail', { + responseSchema: { + 200: ShortUrlWithCustomDomainResponseDto, + 401: DEFAULT_ERROR_RESPONSES[401], + 403: DEFAULT_ERROR_RESPONSES[403], + 404: DEFAULT_ERROR_RESPONSES[404], + 429: DEFAULT_ERROR_RESPONSES[429], + }, + schema: { + summary: 'Get short URL details (authenticated)', + description: + 'Fetches details for a short URL owned by the authenticated user. Only the owner can access this endpoint.', + operationId: 'short-url/get-short-url-detail', + }, + }) + async getDetail( + request: IHttpRequest, + ): Promise> { + const shortUrl = await this.fetchShortUrl(request.params.shortCode, request.user.id); + const tags = await this.tagRepository.findTagsByShortUrlId(shortUrl.id); + return this.makeApiHttpResponse( + 200, + ShortUrlWithCustomDomainResponseDto.parse({ ...shortUrl, tags }), + ); + } + @Get('/:shortCode', { authHandler: false, schema: { @@ -79,15 +211,23 @@ export class ShortUrlController extends AbstractController { request: IHttpRequest, ): Promise> { const shortUrl = await this.fetchShortUrl(request.params.shortCode, request.user.id); + + if (shortUrl.qrCodeId != null) { + throw new BadRequestError( + 'Cannot update a short URL linked to a QR code. Update the QR code instead.', + ); + } + const updatedShortUrl = await this.updateShortUrlUseCase.execute( shortUrl, request.body, request.user.id, ); + const tags = await this.tagRepository.findTagsByShortUrlId(updatedShortUrl.id); return this.makeApiHttpResponse( 200, - ShortUrlWithCustomDomainResponseDto.parse(updatedShortUrl), + ShortUrlWithCustomDomainResponseDto.parse({ ...updatedShortUrl, tags }), ); } @@ -116,9 +256,10 @@ export class ShortUrlController extends AbstractController { request.user.id, ); + const tags = await this.tagRepository.findTagsByShortUrlId(updatedShortUrl.id); return this.makeApiHttpResponse( 200, - ShortUrlWithCustomDomainResponseDto.parse(updatedShortUrl), + ShortUrlWithCustomDomainResponseDto.parse({ ...updatedShortUrl, tags }), ); } diff --git a/apps/backend/src/modules/url-shortener/useCase/__tests__/create-short-url.use-case.test.ts b/apps/backend/src/modules/url-shortener/useCase/__tests__/create-short-url.use-case.test.ts index d6c8694d..d21d647c 100644 --- a/apps/backend/src/modules/url-shortener/useCase/__tests__/create-short-url.use-case.test.ts +++ b/apps/backend/src/modules/url-shortener/useCase/__tests__/create-short-url.use-case.test.ts @@ -4,7 +4,6 @@ import type { CustomDomainValidationService } from '@/modules/custom-domain/serv import { type Logger } from '@/core/logging'; import { mock } from 'jest-mock-extended'; import type { TShortUrlWithDomain } from '../../domain/entities/short-url.entity'; -import type { TCreateShortUrlDto } from '@shared/schemas'; describe('CreateShortUrlUseCase', () => { let useCase: CreateShortUrlUseCase; @@ -28,7 +27,7 @@ describe('CreateShortUrlUseCase', () => { }); describe('execute', () => { - const mockDto: TCreateShortUrlDto = { + const mockDto = { destinationUrl: 'https://example.com', customDomainId: null, isActive: true, @@ -41,6 +40,7 @@ describe('CreateShortUrlUseCase', () => { const mockCreatedShortUrl: TShortUrlWithDomain = { id: mockId, shortCode: mockShortCode, + name: null, destinationUrl: mockDto.destinationUrl, customDomainId: null, customDomain: null, @@ -75,8 +75,8 @@ describe('CreateShortUrlUseCase', () => { }); it('should create short URL with null destinationUrl for reserved URLs', async () => { - const reservedDto: TCreateShortUrlDto = { - destinationUrl: null, + const reservedDto = { + destinationUrl: null as string | null, customDomainId: null, isActive: false, }; @@ -159,6 +159,7 @@ describe('CreateShortUrlUseCase', () => { expect(mockRepository.create).toHaveBeenCalledWith({ id: mockId, shortCode: mockShortCode, + name: null, destinationUrl: mockDto.destinationUrl, customDomainId: mockDto.customDomainId, isActive: mockDto.isActive, diff --git a/apps/backend/src/modules/url-shortener/useCase/__tests__/get-reserved-short-url.use-case.test.ts b/apps/backend/src/modules/url-shortener/useCase/__tests__/get-reserved-short-url.use-case.test.ts index c4259474..c7f29f30 100644 --- a/apps/backend/src/modules/url-shortener/useCase/__tests__/get-reserved-short-url.use-case.test.ts +++ b/apps/backend/src/modules/url-shortener/useCase/__tests__/get-reserved-short-url.use-case.test.ts @@ -36,6 +36,7 @@ describe('GetReservedShortCodeUseCase', () => { const mockReservedShortUrl: TShortUrl = { id: 'short_url_123', shortCode: 'ABC12', + name: null, destinationUrl: null, customDomainId: null, isActive: false, diff --git a/apps/backend/src/modules/url-shortener/useCase/create-short-url.use-case.ts b/apps/backend/src/modules/url-shortener/useCase/create-short-url.use-case.ts index 97984958..71be50be 100644 --- a/apps/backend/src/modules/url-shortener/useCase/create-short-url.use-case.ts +++ b/apps/backend/src/modules/url-shortener/useCase/create-short-url.use-case.ts @@ -3,9 +3,19 @@ import { inject, injectable } from 'tsyringe'; import { Logger } from '@/core/logging'; import ShortUrlRepository from '../domain/repository/short-url.repository'; import { TShortUrl, TShortUrlWithDomain } from '../domain/entities/short-url.entity'; -import { TCreateShortUrlDto } from '@shared/schemas'; import { CustomDomainValidationService } from '@/modules/custom-domain/service/custom-domain-validation.service'; +/** + * Internal input type for creating a short URL. + * Broader than the API DTO — allows null destinationUrl for reserved URLs (QR code flow). + */ +type CreateShortUrlInput = { + destinationUrl: string | null; + isActive: boolean; + customDomainId?: string | null; + name?: string | null; +}; + /** * Use case for creating a ShortUrl entity. */ @@ -24,7 +34,7 @@ export class CreateShortUrlUseCase implements IBaseUseCase { * @param createdBy The ID of the user who created the ShortUrl. * @returns A promise that resolves with the newly created ShortUrl entity. */ - async execute(dto: TCreateShortUrlDto, createdBy: string): Promise { + async execute(dto: CreateShortUrlInput, createdBy: string): Promise { // Validate custom domain ownership and readiness if provided if (dto.customDomainId) { await this.customDomainValidationService.validateForUserUse(dto.customDomainId, createdBy); @@ -36,6 +46,7 @@ export class CreateShortUrlUseCase implements IBaseUseCase { const shortUrl: Omit = { id: newId, shortCode, + name: dto.name ?? null, qrCodeId: null, customDomainId: dto.customDomainId ?? null, destinationUrl: dto.destinationUrl, diff --git a/apps/backend/src/modules/url-shortener/useCase/delete-short-url.use-case.ts b/apps/backend/src/modules/url-shortener/useCase/delete-short-url.use-case.ts new file mode 100644 index 00000000..d9c2ce45 --- /dev/null +++ b/apps/backend/src/modules/url-shortener/useCase/delete-short-url.use-case.ts @@ -0,0 +1,43 @@ +import { IBaseUseCase } from '@/core/interface/base-use-case.interface'; +import { inject, injectable } from 'tsyringe'; +import { Logger } from '@/core/logging'; +import ShortUrlRepository from '../domain/repository/short-url.repository'; +import { TShortUrl } from '../domain/entities/short-url.entity'; +import { BadRequestError } from '@/core/error/http'; + +/** + * Use case for soft-deleting a standalone short URL. + */ +@injectable() +export class DeleteShortUrlUseCase implements IBaseUseCase { + constructor( + @inject(ShortUrlRepository) private shortUrlRepository: ShortUrlRepository, + @inject(Logger) private logger: Logger, + ) {} + + /** + * Soft-deletes a standalone short URL by setting deletedAt. + * Only allows deletion of standalone short URLs (qrCodeId IS NULL). + * @param shortUrl The short URL entity to delete. + * @param userId The ID of the user performing the deletion. + */ + async execute(shortUrl: TShortUrl, userId: string): Promise { + if (shortUrl.qrCodeId != null) { + throw new BadRequestError( + 'Cannot delete a short URL linked to a QR code. Delete the QR code instead.', + ); + } + + await this.shortUrlRepository.update(shortUrl, { + deletedAt: new Date(), + }); + + this.logger.info('shortUrl.deleted', { + shortUrl: { + id: shortUrl.id, + shortCode: shortUrl.shortCode, + deletedBy: userId, + }, + }); + } +} diff --git a/apps/backend/src/modules/url-shortener/useCase/list-short-urls.use-case.ts b/apps/backend/src/modules/url-shortener/useCase/list-short-urls.use-case.ts new file mode 100644 index 00000000..b3167b6b --- /dev/null +++ b/apps/backend/src/modules/url-shortener/useCase/list-short-urls.use-case.ts @@ -0,0 +1,69 @@ +import { IBaseUseCase } from '@/core/interface/base-use-case.interface'; +import { inject, injectable } from 'tsyringe'; +import { ISqlQueryFindBy } from '@/core/interface/repository.interface'; +import ShortUrlRepository from '../domain/repository/short-url.repository'; +import { TShortUrl, TShortUrlWithDomainAndTags } from '../domain/entities/short-url.entity'; +import TagRepository from '@/modules/tag/domain/repository/tag.repository'; + +type ListParams = ISqlQueryFindBy & { + standalone?: boolean; + tagIds?: string[]; +}; + +type ListResponse = { + total: number; + shortUrls: TShortUrlWithDomainAndTags[]; +}; + +/** + * Use case for listing short URLs based on query parameters. + */ +@injectable() +export class ListShortUrlsUseCase implements IBaseUseCase { + constructor( + @inject(ShortUrlRepository) private shortUrlRepository: ShortUrlRepository, + @inject(TagRepository) private tagRepository: TagRepository, + ) {} + + /** + * Executes the use case to retrieve short URLs based on the provided query parameters. + * @param params Query parameters including pagination, filters, and standalone flag. + * @param userId The ID of the user whose short URLs to list. + * @returns An object containing the list of short URLs and the total count. + */ + async execute( + { limit, page, where, standalone, tagIds }: ListParams, + userId: string, + ): Promise { + const shortUrls = await this.shortUrlRepository.findAllWithDomain({ + limit, + page, + where: { + ...where, + createdBy: { eq: userId }, + }, + standalone, + tagIds, + }); + + const total = await this.shortUrlRepository.countTotalFiltered( + { + ...where, + createdBy: { eq: userId }, + }, + standalone, + tagIds, + ); + + // Batch-fetch tags for all returned short URLs + const shortUrlIds = shortUrls.map((su) => su.id); + const tagsMap = await this.tagRepository.findTagsByShortUrlIds(shortUrlIds); + + const shortUrlsWithTags: TShortUrlWithDomainAndTags[] = shortUrls.map((su) => ({ + ...su, + tags: tagsMap.get(su.id) ?? [], + })); + + return { shortUrls: shortUrlsWithTags, total }; + } +} diff --git a/apps/backend/src/modules/url-shortener/useCase/update-short-url.use-case.ts b/apps/backend/src/modules/url-shortener/useCase/update-short-url.use-case.ts index 3f61686f..02e46ff8 100644 --- a/apps/backend/src/modules/url-shortener/useCase/update-short-url.use-case.ts +++ b/apps/backend/src/modules/url-shortener/useCase/update-short-url.use-case.ts @@ -4,13 +4,23 @@ import { Logger } from '@/core/logging'; import { EventEmitter } from '@/core/event'; import ShortUrlRepository from '../domain/repository/short-url.repository'; import { TShortUrl } from '../domain/entities/short-url.entity'; -import { TUpdateShortUrlDto } from '@shared/schemas'; import QrCodeRepository from '@/modules/qr-code/domain/repository/qr-code.repository'; import { QrCodeNotFoundError } from '@/modules/qr-code/error/http/qr-code-not-found.error'; import { RedirectLoopError } from '../error/http/redirect-loop.error'; import { buildShortUrl } from '../utils'; import { CustomDomainValidationService } from '@/modules/custom-domain/service/custom-domain-validation.service'; +/** + * Internal input type for updating a short URL. + * Broader than the API DTO — allows customDomainId for internal flows (QR code strategies). + */ +type UpdateShortUrlInput = { + destinationUrl?: string | null; + isActive?: boolean; + customDomainId?: string | null; + name?: string | null; +}; + /** * Use case for updating a ShortUrl entity. */ @@ -34,7 +44,7 @@ export class UpdateShortUrlUseCase implements IBaseUseCase { */ async execute( shortUrl: TShortUrl, - updatesDto: TUpdateShortUrlDto, + updatesDto: UpdateShortUrlInput, updatedBy: string, linkedQrCodeId?: string, ): Promise { @@ -65,10 +75,11 @@ export class UpdateShortUrlUseCase implements IBaseUseCase { } // Persist the updated ShortUrl entity in the database. - await this.shortUrlRepository.update(shortUrl, { - ...updates, - qrCodeId: linkedQrCodeId, - }); + const updatePayload: Partial = { ...updates }; + if (linkedQrCodeId !== undefined) { + updatePayload.qrCodeId = linkedQrCodeId; + } + await this.shortUrlRepository.update(shortUrl, updatePayload); // Retrieve the updated ShortUrl entity from the database. const result = await this.shortUrlRepository.findOneById(shortUrl.id); diff --git a/apps/backend/src/tests/shared/factories/short-url.factory.ts b/apps/backend/src/tests/shared/factories/short-url.factory.ts index 2e72c8c5..95710055 100644 --- a/apps/backend/src/tests/shared/factories/short-url.factory.ts +++ b/apps/backend/src/tests/shared/factories/short-url.factory.ts @@ -7,21 +7,13 @@ import type { TCreateShortUrlDto, TUpdateShortUrlDto } from '@shared/schemas'; export const generateShortUrlDto = ( overrides?: Partial, ): TCreateShortUrlDto => ({ + name: null, destinationUrl: faker.internet.url(), customDomainId: null, isActive: true, ...overrides, }); -/** - * Generates a reserved short URL DTO (no destination, inactive). - */ -export const generateReservedShortUrlDto = (): TCreateShortUrlDto => ({ - destinationUrl: null, - customDomainId: null, - isActive: false, -}); - /** * Generates a short URL update DTO. */ diff --git a/apps/backend/src/tests/shared/mocks/umami.mock.ts b/apps/backend/src/tests/shared/mocks/umami.mock.ts index 96f001b2..12a345b7 100644 --- a/apps/backend/src/tests/shared/mocks/umami.mock.ts +++ b/apps/backend/src/tests/shared/mocks/umami.mock.ts @@ -74,11 +74,72 @@ export const mockFetchUmamiAnalytics = (data: Partial = { }); }; +let originalFetch: typeof global.fetch | null = null; + +/** + * Mocks the global fetch function for the full Umami analytics flow + * (auth login + multiple data endpoints). + * Only intercepts requests to the Umami host; all other requests pass through. + */ +export const mockFetchUmamiAllEndpoints = () => { + originalFetch = global.fetch; + + const authResponse = createMockUmamiResponse(); + const statsData = { + pageviews: 100, + visitors: 50, + visits: 75, + bounces: 10, + totaltime: 5000, + }; + const pageviewsData = { + pageviews: [ + { x: '2025-01-01', y: 10 }, + { x: '2025-01-02', y: 15 }, + ], + sessions: [ + { x: '2025-01-01', y: 5 }, + { x: '2025-01-02', y: 8 }, + ], + }; + const metricsData = [ + { x: 'Chrome', y: 50 }, + { x: 'Firefox', y: 30 }, + ]; + + global.fetch = jest + .fn() + .mockImplementation(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = + typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url; + + // Only intercept Umami API calls, pass through everything else + if (!url.includes('/api/auth/login') && !url.includes('/api/websites/')) { + return originalFetch!(input, init); + } + + return { + ok: true, + status: 200, + json: async () => { + if (url.includes('/auth/login')) return authResponse; + if (url.includes('/pageviews')) return pageviewsData; + if (url.includes('/metrics')) return metricsData; + return statsData; + }, + text: async () => '', + }; + }); +}; + /** - * Resets all fetch mocks. + * Resets all fetch mocks and restores original fetch. */ export const resetFetchMocks = () => { - if (jest.isMockFunction(global.fetch)) { + if (originalFetch) { + global.fetch = originalFetch; + originalFetch = null; + } else if (jest.isMockFunction(global.fetch)) { (global.fetch as jest.Mock).mockClear(); } }; diff --git a/apps/frontend/content/docs/changelogs.mdx b/apps/frontend/content/docs/changelogs.mdx index 5d95bc5b..0b856535 100644 --- a/apps/frontend/content/docs/changelogs.mdx +++ b/apps/frontend/content/docs/changelogs.mdx @@ -8,58 +8,50 @@ Here you can see all recent updates, new features, improvements, and fixes — e Stay up-to-date with the latest features and improvements in QRcodly. -## Latest Release: 1.9.0 - 2026-03-04 +## Latest Release: 1.10.0 - 2026-03-12 -

🚀 Release Notes – Version 1.9.0

+

🚀 Release Notes – Version 1.10.0

✨ New Features

    -
  • Analytics Integrations (Pro): Connect Google Analytics 4 or Matomo to receive QR code scan events directly in your own analytics dashboard. Configure your tracking credentials, test the connection, and enable or disable providers — all from the new Integrations settings page. Credentials are stored encrypted for security.
  • -
  • Browser Extension Overhaul: Completely redesigned browser extension with built-in authentication, the full QR code generator, and a polished new look.
  • -
  • Smart Tips: Contextual tips that help you discover features as you use the dashboard.
  • -
  • Create Template Dialog: Save your current QR code design as a reusable template with one click.
  • -
  • Client-Side CSV Validation: Bulk import now checks your CSV file before uploading, with a detailed error view to fix issues quickly.
  • -
  • Analytics Integrations Guide: New documentation guide with setup instructions and troubleshooting tips for GA4 and Matomo.
  • -
- -

💳 Billing & Subscription

-
    -
  • Stripe Billing: Subscription management is now powered by Stripe for a more reliable and flexible experience.
  • -
  • Improved Subscription Emails: Better email notifications for subscription events like welcome, renewal, and cancellation.
  • -
  • Pro Feature Enforcement: Pro-only features (Custom Domains, Analytics Integrations) are automatically disabled when a subscription expires and re-enabled when reactivated.
  • -
  • Unlimited Tags on Pro: Pro plan users can now create unlimited tags.
  • +
  • Short URL Management: Full standalone short URL experience — create, edit, delete, and view short URLs with a dedicated dashboard, detail pages with analytics, paginated searchable lists, and a hero form for quick creation.
  • +
  • Short URL Tagging: Full tagging system for short URLs — assign, remove, and filter by tags, with tag management integrated into the list and detail views.
  • +
  • Product Pages: Dedicated product pages for URL Shortener, QR Codes, and Analytics with hero sections, feature details, mockups, FAQs, and CTAs.
  • +
  • Products Navigation Dropdown: New products dropdown menu in the header for easy access to all product pages.
  • +
  • URL Validation: HTTP URL validation for QR code and short URL creation, ensuring only valid URLs are accepted.

🎨 UI & UX Improvements

    -
  • Updated Pricing Page: Refreshed layout with clearer plan comparisons.
  • -
  • New Logo & Branding: New QRcodly logo across the app, updated favicons, and logo in email templates.
  • -
  • Dashboard Polish: Improved sidebar, header buttons, and QR code list actions.
  • -
  • Team Workspaces Preview: Sneak peek at the upcoming Team Workspaces feature on the features page.
  • +
  • Dashboard Polish: Reordered sidebar navigation, improved short URL list with search, sort, and tag filtering.
  • +
  • Product Showcase: CrossProductCards and ProductUseCases components for cross-linking between product pages.
  • +
  • Stats Bar: New stats bar on the home page showcasing key product metrics.
  • +
  • Footer & Header: Language dropdown opens upward, refined header navigation with products dropdown.

🛠 Technical Improvements

    -
  • Backend & Frontend Refactoring: Cleaner code structure with extracted components and improved patterns.
  • -
  • Short URL Soft-Delete: Deleted short URLs are now soft-deleted for safer data management.
  • -
  • Performance: Added caching for scan counts and subscription plans for faster page loads.
  • -
  • SEO: Fixed canonical URLs, added API robots.txt, and improved navigation.
  • +
  • Backend Architecture: Full module structure for short URL management — entities, repositories, use cases, controllers, and event handlers.
  • +
  • Expanded Test Coverage: Comprehensive backend tests for short URL CRUD, tag flows, URL validation, and edge cases.
  • +
  • PostHog & Sentry: Event tracking and error reporting for short URL operations.
-

🔐 Security

+

🐛 Bug Fixes

    -
  • SSRF Hardening: Strengthened protections against server-side request forgery, including HTTPS enforcement and private IP range blocking.
  • -
  • IPv6 Anonymization: Improved IP address anonymization for better privacy compliance.
  • -
  • Encrypted Credentials: Analytics integration credentials are encrypted at rest.
  • +
  • Fixed delete error toast showing confirmation title instead of error message.
  • +
  • Fixed toggle mutation refetching wrong query key.
  • +
  • Fixed stale detail page after short URL edit.
  • +
  • Fixed tag update toast showing wrong label for short URL tags.
  • +
  • Fixed page clamping when result set shrinks after deletion.

🌍 Translations

  • Updated translations across all supported languages (English, German, Spanish, French, Italian, Dutch, Polish, Russian).
  • -
  • Added new translation keys for analytics integrations, billing, smart tips, and more.
  • +
  • Added new translation keys for short URL management, product pages, tagging, and dashboard features.
@@ -69,6 +61,61 @@ Here you can see all recent updates, new features, improvements, and fixes — e ## Previous Releases + +

🚀 Release Notes – Version 1.9.0

+

✨ New Features

+
    +
  • Analytics Integrations (Pro): Connect Google Analytics 4 or Matomo to receive QR code scan events directly in your own analytics dashboard. Configure your tracking credentials, test the connection, and enable or disable providers — all from the new Integrations settings page. Credentials are stored encrypted for security.
  • +
  • Browser Extension Overhaul: Completely redesigned browser extension with built-in authentication, the full QR code generator, and a polished new look.
  • +
  • Smart Tips: Contextual tips that help you discover features as you use the dashboard.
  • +
  • Create Template Dialog: Save your current QR code design as a reusable template with one click.
  • +
  • Client-Side CSV Validation: Bulk import now checks your CSV file before uploading, with a detailed error view to fix issues quickly.
  • +
  • Analytics Integrations Guide: New documentation guide with setup instructions and troubleshooting tips for GA4 and Matomo.
  • +
+ +

💳 Billing & Subscription

+
    +
  • Stripe Billing: Subscription management is now powered by Stripe for a more reliable and flexible experience.
  • +
  • Improved Subscription Emails: Better email notifications for subscription events like welcome, renewal, and cancellation.
  • +
  • Pro Feature Enforcement: Pro-only features (Custom Domains, Analytics Integrations) are automatically disabled when a subscription expires and re-enabled when reactivated.
  • +
  • Unlimited Tags on Pro: Pro plan users can now create unlimited tags.
  • +
+ +

🎨 UI & UX Improvements

+
    +
  • Updated Pricing Page: Refreshed layout with clearer plan comparisons.
  • +
  • New Logo & Branding: New QRcodly logo across the app, updated favicons, and logo in email templates.
  • +
  • Dashboard Polish: Improved sidebar, header buttons, and QR code list actions.
  • +
  • Team Workspaces Preview: Sneak peek at the upcoming Team Workspaces feature on the features page.
  • +
+ +

🛠 Technical Improvements

+
    +
  • Backend & Frontend Refactoring: Cleaner code structure with extracted components and improved patterns.
  • +
  • Short URL Soft-Delete: Deleted short URLs are now soft-deleted for safer data management.
  • +
  • Performance: Added caching for scan counts and subscription plans for faster page loads.
  • +
  • SEO: Fixed canonical URLs, added API robots.txt, and improved navigation.
  • +
+ +

🔐 Security

+
    +
  • SSRF Hardening: Strengthened protections against server-side request forgery, including HTTPS enforcement and private IP range blocking.
  • +
  • IPv6 Anonymization: Improved IP address anonymization for better privacy compliance.
  • +
  • Encrypted Credentials: Analytics integration credentials are encrypted at rest.
  • +
+ +

🌍 Translations

+
    +
  • Updated translations across all supported languages (English, German, Spanish, French, Italian, Dutch, Polish, Russian).
  • +
  • Added new translation keys for analytics integrations, billing, smart tips, and more.
  • +
+ + + } + /> + + + + + + + + + + + + + + + + + + + + + + + + + + + QRcodly + diff --git a/apps/frontend/src/app/(legal)/layout.tsx b/apps/frontend/src/app/(legal)/layout.tsx index f59b545a..56f60483 100644 --- a/apps/frontend/src/app/(legal)/layout.tsx +++ b/apps/frontend/src/app/(legal)/layout.tsx @@ -39,7 +39,7 @@ export default function Layout({ children }: { children: ReactNode }) {
{children} -
+
diff --git a/apps/frontend/src/app/[locale]/dashboard/qr-codes/[id]/edit/page.tsx b/apps/frontend/src/app/[locale]/dashboard/qr-codes/[id]/edit/page.tsx index e227952b..f6866504 100644 --- a/apps/frontend/src/app/[locale]/dashboard/qr-codes/[id]/edit/page.tsx +++ b/apps/frontend/src/app/[locale]/dashboard/qr-codes/[id]/edit/page.tsx @@ -8,7 +8,7 @@ import { QrCodeGeneratorStoreProvider } from '@/components/provider/QrCodeConfig import { getTranslations } from 'next-intl/server'; import { PencilSquareIcon } from '@heroicons/react/24/outline'; import { EditPageTagSection } from '@/components/qr-code-detail/EditPageTagSection'; -import Link from 'next/link'; +import { Link } from '@/i18n/navigation'; import { Card, CardContent } from '@/components/ui/card'; import { Breadcrumb, @@ -93,13 +93,13 @@ export default async function QRCodeEditPage({ params }: QRCodeEditProps) { - {t('collection.tabQrCode')} + {t('collection.tabQrCode')} - + {qrCode.name || t('general.noName')} diff --git a/apps/frontend/src/app/[locale]/dashboard/settings/api-keys/page.tsx b/apps/frontend/src/app/[locale]/dashboard/settings/api-keys/page.tsx index df6e2315..e842578f 100644 --- a/apps/frontend/src/app/[locale]/dashboard/settings/api-keys/page.tsx +++ b/apps/frontend/src/app/[locale]/dashboard/settings/api-keys/page.tsx @@ -2,6 +2,7 @@ import { CodeBracketIcon } from '@heroicons/react/24/outline'; import { Card, CardContent, CardDescription, CardTitle } from '@/components/ui/card'; import { currentUser } from '@clerk/nextjs/server'; import { getTranslations } from 'next-intl/server'; +import Link from 'next/link'; import type { DefaultPageParams } from '@/types/page'; import { ApiKeyList } from '@/components/dashboard/api-keys/ApiKeyList'; import { CreateApiKeyDialog } from '@/components/dashboard/api-keys/CreateApiKeyDialog'; @@ -28,7 +29,19 @@ export default async function Page({ params }: DefaultPageParams) {
{t('title')} -
{t('description')}
+
+ {t.rich('description', { + link: (chunks) => ( + + {chunks} + + ), + })} +
diff --git a/apps/frontend/src/app/[locale]/dashboard/short-urls/[shortCode]/page.tsx b/apps/frontend/src/app/[locale]/dashboard/short-urls/[shortCode]/page.tsx new file mode 100644 index 00000000..2df1b1b4 --- /dev/null +++ b/apps/frontend/src/app/[locale]/dashboard/short-urls/[shortCode]/page.tsx @@ -0,0 +1,42 @@ +import { apiRequest } from '@/lib/utils'; +import { notFound } from 'next/navigation'; +import { auth } from '@clerk/nextjs/server'; +import type { TShortUrlWithCustomDomainResponseDto } from '@shared/schemas'; +import { ShortUrlDetailContent } from '@/components/dashboard/shortUrl/ShortUrlDetailContent'; + +interface ShortUrlDetailProps { + params: Promise<{ + shortCode: string; + }>; +} + +export const dynamic = 'force-dynamic'; + +export default async function ShortUrlDetailPage({ params }: ShortUrlDetailProps) { + const { shortCode } = await params; + + let shortUrl: TShortUrlWithCustomDomainResponseDto | null = null; + try { + const { getToken } = await auth(); + const token = await getToken(); + + shortUrl = await apiRequest( + `/short-url/${shortCode}/detail`, + { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + }, + ); + } catch (error) { + console.error('Failed to fetch short URL details:', error); + } + + if (!shortUrl) { + notFound(); + } + + return ; +} diff --git a/apps/frontend/src/app/[locale]/dashboard/short-urls/page.tsx b/apps/frontend/src/app/[locale]/dashboard/short-urls/page.tsx new file mode 100644 index 00000000..3d3a28bc --- /dev/null +++ b/apps/frontend/src/app/[locale]/dashboard/short-urls/page.tsx @@ -0,0 +1,36 @@ +'use client'; + +import { ShortUrlList } from '@/components/dashboard/shortUrl/ShortUrlList'; +import { Card, CardContent, CardDescription, CardTitle } from '@/components/ui/card'; +import { LinkIcon } from '@heroicons/react/24/outline'; +import { useTranslations } from 'next-intl'; +import { CreateShortUrlDialog } from '@/components/dashboard/shortUrl/CreateShortUrlDialog'; + +export default function ShortUrlsPage() { + const t = useTranslations('shortUrl'); + + return ( + <> + + +
+
+
+ +
+
+ {t('title')} + {t('description')} +
+
+
+ +
+
+
+
+ + + + ); +} diff --git a/apps/frontend/src/app/[locale]/dashboard/templates/[id]/edit/page.tsx b/apps/frontend/src/app/[locale]/dashboard/templates/[id]/edit/page.tsx index 2b01e1f3..c76a269f 100644 --- a/apps/frontend/src/app/[locale]/dashboard/templates/[id]/edit/page.tsx +++ b/apps/frontend/src/app/[locale]/dashboard/templates/[id]/edit/page.tsx @@ -7,7 +7,7 @@ import { QRcodeGenerator } from '@/components/qr-generator/QRcodeGenerator'; import { QrCodeGeneratorStoreProvider } from '@/components/provider/QrCodeConfigStoreProvider'; import { getTranslations } from 'next-intl/server'; import { ChevronLeftIcon } from '@heroicons/react/24/outline'; -import Link from 'next/link'; +import { Link } from '@/i18n/navigation'; import { env } from '@/env'; interface ConfigTemplateEditProps { @@ -48,7 +48,7 @@ export default async function ConfigTemplateEditPage({ params }: ConfigTemplateE const backLink = ( {t('general.backToOverview')} @@ -67,6 +67,17 @@ export default async function ConfigTemplateEditPage({ params }: ConfigTemplateE isEditable: true, }, }, + latestQrCode: { + name: template.name ?? undefined, + config: template.config, + content: { + type: 'url', + data: { + url: env.NEXT_PUBLIC_FRONTEND_URL, + isEditable: true, + }, + }, + }, bulkMode: { isBulkMode: false, file: undefined, diff --git a/apps/frontend/src/app/[locale]/page.tsx b/apps/frontend/src/app/[locale]/page.tsx index 65bbaf0e..288366fa 100644 --- a/apps/frontend/src/app/[locale]/page.tsx +++ b/apps/frontend/src/app/[locale]/page.tsx @@ -11,6 +11,7 @@ import { auth } from '@clerk/nextjs/server'; import { notFound } from 'next/navigation'; import { SUPPORTED_LANGUAGES } from '@/i18n/routing'; import { Hero } from '@/components/Hero'; +import { ProductStatsBar } from '@/components/products/ProductStatsBar'; import dynamic from 'next/dynamic'; // Dynamic imports for below-the-fold components to reduce initial bundle size @@ -25,6 +26,10 @@ const ProductShowcase = dynamic( const Cta = dynamic(() => import('@/components/Cta').then((mod) => mod.Cta), { ssr: true, }); +const BrowserExtensionTeaser = dynamic( + () => import('@/components/BrowserExtensionTeaser').then((mod) => mod.BrowserExtensionTeaser), + { ssr: true }, +); const FAQSection = dynamic(() => import('@/components/Faq'), { ssr: true, }); @@ -36,6 +41,7 @@ export default async function Page({ params }: DefaultPageParams) { } const tMeta = await getTranslations({ locale, namespace: 'metadata' }); + const tStats = await getTranslations({ locale, namespace: 'homeStats' }); const { userId } = await auth(); const isSignedIn = !!userId; @@ -103,6 +109,14 @@ export default async function Page({ params }: DefaultPageParams) { + {/* Stats Bar */} + ({ + value: tStats(`stat${i + 1}Value`), + label: tStats(`stat${i + 1}Label`), + }))} + /> + {/* Features Slider */}
@@ -113,6 +127,11 @@ export default async function Page({ params }: DefaultPageParams) {
+ {/* Browser Extension Teaser */} +
+ +
+ {/* FAQ Section */}
diff --git a/apps/frontend/src/app/[locale]/plans/page.tsx b/apps/frontend/src/app/[locale]/plans/page.tsx index e5ef1445..26b531e1 100644 --- a/apps/frontend/src/app/[locale]/plans/page.tsx +++ b/apps/frontend/src/app/[locale]/plans/page.tsx @@ -3,6 +3,7 @@ import Footer from '@/components/Footer'; import Header from '@/components/Header'; import { PricingCard } from '@/components/plans/PricingCard'; import Container from '@/components/ui/container'; +import { Heading } from '@/components/ui/heading'; import { env } from '@/env'; import { routing, SUPPORTED_LANGUAGES } from '@/i18n/routing'; import type { DefaultPageParams } from '@/types/page'; @@ -63,9 +64,9 @@ export default async function Page({ params }: DefaultPageParams) {
-

+ {t('title')} -

+

{t('subtitle')}

diff --git a/apps/frontend/src/app/[locale]/products/analytics/page.tsx b/apps/frontend/src/app/[locale]/products/analytics/page.tsx new file mode 100644 index 00000000..f21d52d3 --- /dev/null +++ b/apps/frontend/src/app/[locale]/products/analytics/page.tsx @@ -0,0 +1,210 @@ +import Header from '@/components/Header'; +import Footer from '@/components/Footer'; +import { ProductHeroSection } from '@/components/products/ProductHeroSection'; +import { ProductFeatureSection } from '@/components/products/ProductFeatureSection'; + +import { ProductUseCases } from '@/components/products/ProductUseCases'; +import { CrossProductCards } from '@/components/products/CrossProductCards'; +import { ProductFaqSection } from '@/components/products/ProductFaqSection'; +import { + RealTimeMetricsMockup, + ChannelComparisonMockup, + IntegrationsDashboardMockup, + GeographicInsightsMockup, + ExportReportingMockup, + PrivacyFirstMockup, +} from '@/components/products/mockups/AnalyticsMockups'; +import { + LinkIcon, + QrCodeIcon, + AdjustmentsHorizontalIcon, + MapPinIcon, + DevicePhoneMobileIcon, + DocumentChartBarIcon, + ArrowsRightLeftIcon, + ArrowsPointingInIcon, +} from '@heroicons/react/24/outline'; +import type { DefaultPageParams } from '@/types/page'; +import { getTranslations } from 'next-intl/server'; +import { notFound } from 'next/navigation'; +import { routing, SUPPORTED_LANGUAGES } from '@/i18n/routing'; +import type { Metadata } from 'next'; +import { env } from '@/env'; + +const PAGE_PATH = 'products/analytics'; + +export async function generateMetadata({ params }: DefaultPageParams): Promise { + const { locale } = await params; + if (!SUPPORTED_LANGUAGES.includes(locale)) return {}; + const t = await getTranslations({ locale, namespace: 'productsAnalytics' }); + const baseUrl = env.NEXT_PUBLIC_FRONTEND_URL; + + return { + title: t('metaTitle'), + description: t('metaDescription'), + alternates: { + canonical: + locale === routing.defaultLocale + ? `${baseUrl}/${PAGE_PATH}` + : `${baseUrl}/${locale}/${PAGE_PATH}`, + languages: { + 'x-default': `${baseUrl}/${PAGE_PATH}`, + ...Object.fromEntries( + routing.locales + .filter((l) => l !== locale) + .map((l) => [ + l, + l === routing.defaultLocale + ? `${baseUrl}/${PAGE_PATH}` + : `${baseUrl}/${l}/${PAGE_PATH}`, + ]), + ), + }, + }, + }; +} + +export default async function Page({ params }: DefaultPageParams) { + const { locale } = await params; + if (!SUPPORTED_LANGUAGES.includes(locale)) notFound(); + + const t = await getTranslations({ locale, namespace: 'productsAnalytics' }); + + const features = [ + { + title: t('features.realTime.title'), + description: t('features.realTime.description'), + bullets: [ + t('features.realTime.bullet1'), + t('features.realTime.bullet2'), + t('features.realTime.bullet3'), + ], + visual: , + }, + { + title: t('features.channels.title'), + comingSoon: t('features.channels.comingSoon'), + description: t('features.channels.description'), + bullets: [ + t('features.channels.bullet1'), + t('features.channels.bullet2'), + t('features.channels.bullet3'), + ], + visual: , + }, + { + title: t('features.integrations.title'), + description: t('features.integrations.description'), + bullets: [ + t('features.integrations.bullet1'), + t('features.integrations.bullet2'), + t('features.integrations.bullet3'), + ], + visual: , + }, + { + title: t('features.geographic.title'), + description: t('features.geographic.description'), + bullets: [ + t('features.geographic.bullet1'), + t('features.geographic.bullet2'), + t('features.geographic.bullet3'), + ], + visual: , + }, + { + title: t('features.exportReporting.title'), + comingSoon: t('features.exportReporting.comingSoon'), + description: t('features.exportReporting.description'), + bullets: [ + t('features.exportReporting.bullet1'), + t('features.exportReporting.bullet2'), + t('features.exportReporting.bullet3'), + ], + visual: , + }, + { + title: t('features.privacy.title'), + description: t('features.privacy.description'), + bullets: [ + t('features.privacy.bullet1'), + t('features.privacy.bullet2'), + t('features.privacy.bullet3'), + ], + visual: , + }, + ]; + + const useCaseIcons = [ + , + , + , + , + , + , + ]; + + const useCases = Array.from({ length: 6 }, (_, i) => ({ + icon: useCaseIcons[i], + title: t(`useCases.case${i + 1}Title`), + description: t(`useCases.case${i + 1}Description`), + })); + + const faqItems = Array.from({ length: 6 }, (_, i) => ({ + question: t(`faq.q${i + 1}`), + answer: t(`faq.a${i + 1}`), + })); + + return ( + <> +
+
+ + + {features.map((feature, i) => ( + + ))} + + + + , + }, + { + title: t('crossProducts.qrCodes.title'), + description: t('crossProducts.qrCodes.description'), + href: '/products/qr-codes', + icon: , + }, + ]} + /> + + +
+