{children}
+} + +export function DialogFooter({ children, className }: { children: ReactNode; className?: string }) { + returnInvalid URLs:
++ {originalUrl} +
+ )} ++ Destination: {created.url} +
+Total visits
+{urlInfo.visits.toLocaleString()}
+Unique visits
+{urlInfo.uniqueVisits.toLocaleString()}
+Created
+{format(new Date(urlInfo.createdAt), "MMM d, yyyy")}
+Expires
++ {urlInfo.expiresAt ? format(new Date(urlInfo.expiresAt), "MMM d, yyyy") : "Never"} +
+{urlInfo.title}
+ )} + {urlInfo.description && ( +{urlInfo.description}
+ )} +Code
+{urlInfo.code}
+Block bots
+{urlInfo.blockBots ? "Yes" : "No"}
+Password protected
+{urlInfo.hasPassword ? "Yes" : "No"}
+Active at
++ {urlInfo.activeAt ? format(new Date(urlInfo.activeAt), "MMM d, yyyy HH:mm") : "Immediately"} +
+No visits yet
+Total
+{stats.totalVisits.toLocaleString()}
+Unique
+{stats.uniqueVisits.toLocaleString()}
+Daily avg
++ {stats.daily.length > 0 + ? Math.round(stats.totalVisits / stats.daily.length).toLocaleString() + : "0"} +
++ Not enough data yet. Share your link to get started. +
+No hourly data available yet.
+Create your first short link to get started.
+ + + +Try adjusting your filters.
+ +-qr."`
+
+The QR is cached in the `qr_codes` table on first generation. Subsequent requests with the same `(code, format, logo)` combination return the cached version.
+
+**Error Responses**:
+- `404 URL_NOT_FOUND` — code does not exist or is not owned by the requesting user
+- `410 QR_CODE_EXPIRED` — QR has a `qrExpiresAt` date that has passed
+
+**cURL Example**:
+```bash
+curl -H "Authorization: Bearer $TOKEN" \
+ "http://localhost:3000/api/urls/aB3xK9m/qr?format=svg&logo=https://example.com/logo.png" \
+ --output qr-code.svg
+```
+
+---
+
+### `POST /api/urls/:code/qr/regenerate`
+
+Regenerate an expired QR code with a new expiry date.
+
+**Auth**: Required (owner only)
+
+**Rate Limit**: Standard (120 req/min)
+
+**Path Parameters**:
+
+| Param | Type | Description |
+|---|---|---|
+| `code` | string | Short URL code (3-16 alphanumeric) |
+
+**Query Parameters**:
+
+| Param | Type | Default | Description |
+|---|---|---|---|
+| `format` | enum | `png` | `png` or `svg` |
+| `logo` | string | — | URL of an optional logo image to embed in the centre |
+
+**Request Body**:
+```json
+{
+ "expiresAt": "2025-12-31T23:59:59.000Z"
+}
+```
+
+| Field | Type | Required | Description |
+|---|---|---|---|
+| `expiresAt` | string (ISO 8601) | **yes** | New expiry datetime for the QR code |
+
+The `expiresAt` datetime must be in ISO 8601 format.
+
+**Response** `200`: Same as `GET /api/urls/:code/qr` — the new QR code image.
+
+**Error Responses**:
+- `404 URL_NOT_FOUND` — code does not exist or is not owned by the requesting user
+- `400 QR_NOT_YET_EXPIRED` — QR has not expired yet; cannot regenerate until after `qrExpiresAt`
---
@@ -779,6 +847,7 @@ Update a short URL's settings.
{
"activeAt": "2024-08-01T00:00:00.000Z",
"expiresAt": "2025-01-01T00:00:00.000Z",
+ "qrExpiresAt": "2025-06-01T00:00:00.000Z",
"password": "new-password",
"blockBots": true
}
@@ -788,6 +857,7 @@ Update a short URL's settings.
|---|---|---|
| `activeAt` | string \| null | ISO 8601 date or `null` to clear |
| `expiresAt` | string \| null | ISO 8601 date or `null` to clear |
+| `qrExpiresAt` | string \| null | ISO 8601 date or `null` to clear (independent of link expiry) |
| `password` | string \| null | New password or `null` to remove |
| `blockBots` | boolean | Toggle bot blocking |
@@ -2128,6 +2198,7 @@ Stripe webhook receiver. Expects raw JSON body with Stripe signature verificatio
| `blockBots` | BOOLEAN | DEFAULT false, NOT NULL | Block known bots |
| `activeAt` | TIMESTAMP | — | Scheduled activation time |
| `expiresAt` | TIMESTAMP | — | Link expiry time |
+| `qrExpiresAt` | TIMESTAMP | — | QR code expiry time (independent of link expiry) |
| `deletedAt` | TIMESTAMP | — | Soft-delete timestamp |
| `lastCheckedAt` | TIMESTAMP | — | Last health check timestamp |
| `lastStatusCode` | INTEGER | — | Last health check HTTP status |
@@ -2298,3 +2369,18 @@ Hourly aggregated visit statistics (last 7 days available).
| `updatedAt` | TIMESTAMP | DEFAULT NOW(), NOT NULL | Last update time |
**Unique**: `(userId, domain)`, `(domain)`
+
+### `qr_codes`
+
+Cached QR code images, keyed by URL code + format + optional logo.
+
+| Column | Type | Constraints | Description |
+|---|---|---|---|
+| `id` | SERIAL | PK | Auto-increment ID |
+| `code` | TEXT | FK → urls.code, NOT NULL, ON DELETE CASCADE | Short URL code |
+| `format` | TEXT | NOT NULL | `png` or `svg` |
+| `data` | TEXT | NOT NULL | Base64-encoded PNG binary or SVG text |
+| `logoUrl` | TEXT | — | Optional centre logo URL |
+| `createdAt` | TIMESTAMP | DEFAULT NOW(), NOT NULL | Creation time |
+
+**Unique**: `(code, format, COALESCE(logoUrl, ''))`
diff --git a/docs/PROJECT.md b/docs/PROJECT.md
index 7ba5f4e..3da84ca 100644
--- a/docs/PROJECT.md
+++ b/docs/PROJECT.md
@@ -58,6 +58,10 @@ Linkify handles the complete lifecycle of a short link — from creation with cu
- **PNG & SVG Formats** — Download QR codes in either format
- **Logo Embedding** — Optionally embed a logo image in the centre of the QR code
- **Auto-Fetch** — Fetches logo from URL, resizes, and composites
+- **Short URL Encoding** — QR encodes the short URL (not destination), so scans are tracked and respect expiry/password protection
+- **Database Caching** — QR images are cached in the `qr_codes` table; subsequent requests skip re-generation
+- **Time-Limited QR** — Independent `qrExpiresAt` field allows QR expiry separate from link expiry
+- **QR Regeneration** — Expired QR codes can be regenerated with a new expiry date; old cache is purged
### Bulk Operations
- **Bulk Tag** — Assign tags to multiple URLs at once
diff --git a/opencode.json b/opencode.json
index 986631c..11bd67c 100644
--- a/opencode.json
+++ b/opencode.json
@@ -1,7 +1,5 @@
{
"$schema": "https://opencode.ai/config.json",
- "model": "anthropic/claude-sonnet-4-5",
- "small_model": "anthropic/claude-haiku-4-5",
"default_agent": "build",
"instructions": [
"AGENTS.md",
@@ -31,7 +29,6 @@
"build": {
"description": "Primary coding agent for development work",
"mode": "primary",
- "model": "anthropic/claude-sonnet-4-5",
"tools": {
"write": true,
"edit": true,
@@ -43,7 +40,6 @@
"planner": {
"description": "Expert planning specialist for complex features and refactoring. Use for implementation planning, architectural changes, or complex refactoring.",
"mode": "subagent",
- "model": "anthropic/claude-opus-4-5",
"prompt": "{file:.opencode/prompts/agents/planner.txt}",
"tools": {
"read": true,
@@ -55,7 +51,6 @@
"architect": {
"description": "Software architecture specialist for system design, scalability, and technical decision-making.",
"mode": "subagent",
- "model": "anthropic/claude-opus-4-5",
"prompt": "{file:.opencode/prompts/agents/architect.txt}",
"tools": {
"read": true,
@@ -67,7 +62,6 @@
"code-reviewer": {
"description": "Expert code review specialist. Reviews code for quality, security, and maintainability. Use immediately after writing or modifying code.",
"mode": "subagent",
- "model": "anthropic/claude-opus-4-5",
"prompt": "{file:.opencode/prompts/agents/code-reviewer.txt}",
"tools": {
"read": true,
@@ -79,19 +73,17 @@
"security-reviewer": {
"description": "Security vulnerability detection and remediation specialist. Use after writing code that handles user input, authentication, API endpoints, or sensitive data.",
"mode": "subagent",
- "model": "anthropic/claude-opus-4-5",
"prompt": "{file:.opencode/prompts/agents/security-reviewer.txt}",
"tools": {
"read": true,
- "bash": true,
"write": true,
- "edit": true
+ "edit": true,
+ "bash": true
}
},
"tdd-guide": {
"description": "Test-Driven Development specialist enforcing write-tests-first methodology. Use when writing new features, fixing bugs, or refactoring code. Ensures 80%+ test coverage.",
"mode": "subagent",
- "model": "anthropic/claude-opus-4-5",
"prompt": "{file:.opencode/prompts/agents/tdd-guide.txt}",
"tools": {
"read": true,
@@ -103,7 +95,6 @@
"build-error-resolver": {
"description": "Build and TypeScript error resolution specialist. Use when build fails or type errors occur. Fixes build/type errors only with minimal diffs.",
"mode": "subagent",
- "model": "anthropic/claude-opus-4-5",
"prompt": "{file:.opencode/prompts/agents/build-error-resolver.txt}",
"tools": {
"read": true,
@@ -115,7 +106,6 @@
"e2e-runner": {
"description": "End-to-end testing specialist using Playwright. Generates, maintains, and runs E2E tests for critical user flows.",
"mode": "subagent",
- "model": "anthropic/claude-opus-4-5",
"prompt": "{file:.opencode/prompts/agents/e2e-runner.txt}",
"tools": {
"read": true,
@@ -127,7 +117,6 @@
"doc-updater": {
"description": "Documentation and codemap specialist. Use for updating codemaps and documentation.",
"mode": "subagent",
- "model": "anthropic/claude-opus-4-5",
"prompt": "{file:.opencode/prompts/agents/doc-updater.txt}",
"tools": {
"read": true,
@@ -139,7 +128,6 @@
"refactor-cleaner": {
"description": "Dead code cleanup and consolidation specialist. Use for removing unused code, duplicates, and refactoring.",
"mode": "subagent",
- "model": "anthropic/claude-opus-4-5",
"prompt": "{file:.opencode/prompts/agents/refactor-cleaner.txt}",
"tools": {
"read": true,
@@ -151,7 +139,6 @@
"go-reviewer": {
"description": "Expert Go code reviewer specializing in idiomatic Go, concurrency patterns, error handling, and performance.",
"mode": "subagent",
- "model": "anthropic/claude-opus-4-5",
"prompt": "{file:.opencode/prompts/agents/go-reviewer.txt}",
"tools": {
"read": true,
@@ -163,7 +150,6 @@
"go-build-resolver": {
"description": "Go build, vet, and compilation error resolution specialist. Fixes Go build errors with minimal changes.",
"mode": "subagent",
- "model": "anthropic/claude-opus-4-5",
"prompt": "{file:.opencode/prompts/agents/go-build-resolver.txt}",
"tools": {
"read": true,
@@ -175,7 +161,6 @@
"database-reviewer": {
"description": "PostgreSQL database specialist for query optimization, schema design, security, and performance. Incorporates Supabase best practices.",
"mode": "subagent",
- "model": "anthropic/claude-opus-4-5",
"prompt": "{file:.opencode/prompts/agents/database-reviewer.txt}",
"tools": {
"read": true,
@@ -187,7 +172,6 @@
"cpp-reviewer": {
"description": "Expert C++ code reviewer specializing in memory safety, modern C++ idioms, concurrency, and performance. Use for all C++ code changes.",
"mode": "subagent",
- "model": "anthropic/claude-opus-4-5",
"prompt": "{file:.opencode/prompts/agents/cpp-reviewer.txt}",
"tools": {
"read": true,
@@ -199,7 +183,6 @@
"cpp-build-resolver": {
"description": "C++ build, CMake, and compilation error resolution specialist. Fixes build errors, linker issues, and template errors with minimal changes.",
"mode": "subagent",
- "model": "anthropic/claude-opus-4-5",
"prompt": "{file:.opencode/prompts/agents/cpp-build-resolver.txt}",
"tools": {
"read": true,
@@ -211,7 +194,6 @@
"docs-lookup": {
"description": "Documentation specialist using Context7 MCP to fetch current library and API documentation with code examples.",
"mode": "subagent",
- "model": "anthropic/claude-sonnet-4-5",
"prompt": "{file:.opencode/prompts/agents/docs-lookup.txt}",
"tools": {
"read": true,
@@ -223,7 +205,6 @@
"harness-optimizer": {
"description": "Analyze and improve the local agent harness configuration for reliability, cost, and throughput.",
"mode": "subagent",
- "model": "anthropic/claude-sonnet-4-5",
"prompt": "{file:.opencode/prompts/agents/harness-optimizer.txt}",
"tools": {
"read": true,
@@ -234,7 +215,6 @@
"java-reviewer": {
"description": "Expert Java and Spring Boot code reviewer specializing in layered architecture, JPA patterns, security, and concurrency.",
"mode": "subagent",
- "model": "anthropic/claude-opus-4-5",
"prompt": "{file:.opencode/prompts/agents/java-reviewer.txt}",
"tools": {
"read": true,
@@ -246,7 +226,6 @@
"java-build-resolver": {
"description": "Java/Maven/Gradle build, compilation, and dependency error resolution specialist. Fixes build errors with minimal changes.",
"mode": "subagent",
- "model": "anthropic/claude-opus-4-5",
"prompt": "{file:.opencode/prompts/agents/java-build-resolver.txt}",
"tools": {
"read": true,
@@ -258,7 +237,6 @@
"kotlin-reviewer": {
"description": "Kotlin and Android/KMP code reviewer. Reviews Kotlin code for idiomatic patterns, coroutine safety, Compose best practices.",
"mode": "subagent",
- "model": "anthropic/claude-opus-4-5",
"prompt": "{file:.opencode/prompts/agents/kotlin-reviewer.txt}",
"tools": {
"read": true,
@@ -270,7 +248,6 @@
"kotlin-build-resolver": {
"description": "Kotlin/Gradle build, compilation, and dependency error resolution specialist. Fixes Kotlin build errors with minimal changes.",
"mode": "subagent",
- "model": "anthropic/claude-opus-4-5",
"prompt": "{file:.opencode/prompts/agents/kotlin-build-resolver.txt}",
"tools": {
"read": true,
@@ -282,7 +259,6 @@
"loop-operator": {
"description": "Operate autonomous agent loops, monitor progress, and intervene safely when loops stall.",
"mode": "subagent",
- "model": "anthropic/claude-sonnet-4-5",
"prompt": "{file:.opencode/prompts/agents/loop-operator.txt}",
"tools": {
"read": true,
@@ -293,7 +269,6 @@
"php-reviewer": {
"description": "Expert PHP code reviewer specializing in PSR-12 compliance, PHP type system, Eloquent ORM patterns, security, and performance.",
"mode": "subagent",
- "model": "anthropic/claude-opus-4-5",
"prompt": "{file:.opencode/prompts/agents/php-reviewer.txt}",
"tools": {
"read": true,
@@ -305,7 +280,6 @@
"python-reviewer": {
"description": "Expert Python code reviewer specializing in PEP 8 compliance, Pythonic idioms, type hints, security, and performance.",
"mode": "subagent",
- "model": "anthropic/claude-opus-4-5",
"prompt": "{file:.opencode/prompts/agents/python-reviewer.txt}",
"tools": {
"read": true,
@@ -317,7 +291,6 @@
"rust-reviewer": {
"description": "Expert Rust code reviewer specializing in idiomatic Rust, ownership, lifetimes, concurrency, and performance.",
"mode": "subagent",
- "model": "anthropic/claude-opus-4-5",
"prompt": "{file:.opencode/prompts/agents/rust-reviewer.txt}",
"tools": {
"read": true,
@@ -329,7 +302,6 @@
"rust-build-resolver": {
"description": "Rust build, Cargo, and compilation error resolution specialist. Fixes Rust build errors with minimal changes.",
"mode": "subagent",
- "model": "anthropic/claude-opus-4-5",
"prompt": "{file:.opencode/prompts/agents/rust-build-resolver.txt}",
"tools": {
"read": true,
@@ -475,15 +447,15 @@
},
"mcp": {
"21st": {
- "type":"remote",
+ "type": "remote",
"url": "https://21st.dev/api/mcp",
- "enabled":true,
+ "enabled": true,
"headers": {
- "x-api-key": "21st_sk_e97f6ad7dba85817321fea2aeb876ba733738c50511181f8610ba07026a4c697"
+ "x-api-key": "${21ST_API_KEY}"
}
}
},
"permission": {
"mcp_*": "ask"
}
-}
\ No newline at end of file
+}
diff --git a/phases.md b/phases.md
index 3977bd0..44e70fe 100644
--- a/phases.md
+++ b/phases.md
@@ -54,7 +54,7 @@ After auth in `apps/web`, the user is redirected to `apps/dashboard` (port 5174)
#### Shared types (`packages/shared/src/index.ts`)
Add TypeScript interfaces/types for all API response shapes:
-- `ShortUrl` — `{ code, url, shortUrl, title, description, image, visits, uniqueVisits, expiresAt, activeAt, hasPassword, blockBots, createdAt }`
+- `ShortUrl` — `{ code, url, shortUrl, title, description, image, visits, uniqueVisits, expiresAt, activeAt, hasPassword, blockBots, qrExpiresAt, createdAt }`
- `Visit` — `{ id, code, ipAddress, userAgent, referer, country, city, deviceType, os, browser, browserVersion, referrerCategory, isBot, visitedAt }`
- `VisitStats` — `{ totalVisits, uniqueVisits, hourly: VisitStat[], daily: VisitStat[] }`
- `VisitStat` — `{ hour/date, visits, uniqueVisits }`
@@ -76,7 +76,7 @@ Add TypeScript interfaces/types for all API response shapes:
- Export typed helper functions for every endpoint (one per API call):
- Auth: `fetchMe`, `refreshToken`, `resetPassword`
- API Keys: `createApiKey`, `listApiKeys`, `updateApiKey`, `revokeApiKey`
- - URLs: `createUrl`, `bulkCreateUrls`, `listUrls`, `getUrlInfo`, `getUrlVisits`, `getUrlStats`, `exportVisitsCsv`, `generateQrCode`, `updateUrlSettings`, `setUrlPassword`, `removeUrlPassword`, `verifyUrlPassword`, `softDeleteUrl`, `purgeUrl`, `bulkOperations`, `importCsv`
+ - URLs: `createUrl`, `bulkCreateUrls`, `listUrls`, `getUrlInfo`, `getUrlVisits`, `getUrlStats`, `exportVisitsCsv`, `generateQrCode`, `regenerateQrCode`, `updateUrlSettings`, `setUrlPassword`, `removeUrlPassword`, `verifyUrlPassword`, `softDeleteUrl`, `purgeUrl`, `bulkOperations`, `importCsv`
- Collections: `listCollections`, `createCollection`, `getCollection`, `updateCollection`, `deleteCollection`, `reorderCollections`, `shareCollection`, `revokeCollectionShare`, `getCollectionUrls`, `addUrlToCollection`, `removeUrlFromCollection`
- Tags: `listTags`, `createTag`, `updateTag`, `deleteTag`, `bulkTagUrls`, `getTagUrls`
- Billing: `listPlans`, `createCheckoutSession`, `getPortalUrl`, `getSubscription`, `cancelSubscription`, `getUsage`
@@ -240,6 +240,7 @@ Each page component is a placeholder ` ` — actual con
- **TTL Days** (optional) — number input or preset buttons (7d, 30d, 90d, 365d, never)
- **Password** (optional) — password input, only shown if plan allows
- **Active at** (optional) — date-time picker (scheduled activation)
+ - **QR expires at** (optional) — date-time picker (QR code expiry, independent of link TTL)
- **Block bots** — toggle switch
- **Tags** — multi-select of existing tags, with create-new-tag inline option
- **Collection** — dropdown to select a collection
@@ -303,11 +304,17 @@ Each page component is a placeholder ` ` — actual con
- Loading state while file generates
#### QR code
+- QR encodes the **short URL** (not destination) → scans go through the redirect: tracked, time-limited, password-checked
- Button/modal "Generate QR Code"
- Format selector: PNG / SVG
- Optional logo URL input
- Preview of the QR code in the modal
- Download button for PNG or SVG
+- Expiry badge: shows `qrExpiresAt` if set; "Expired" state when past
+- **"Regenerate QR"** — visible when QR is expired
+ - Confirmation dialog → calls `POST /:code/qr/regenerate` with new `expiresAt`
+ - Replaces the stored QR (old cache is purged, new QR generated)
+- QR is cached in DB on first generation; subsequent loads return cached version
- Loading state while QR generates
#### Page sections navigation
@@ -326,6 +333,7 @@ Each page component is a placeholder ` ` — actual con
- Edit form using `PATCH /api/urls/:code/settings`:
- **Active at** — date-time picker (null = no schedule)
- **Expires at** — date-time picker (null = never expires)
+ - **QR expires at** — date-time picker (null = never expires; independent of link expiry)
- **Block bots** — toggle switch
- "Clear" buttons next to date fields to set null
- Save button with loading state and success toast
diff --git a/specs/03-third-fourth-phase.md b/specs/03-third-fourth-phase.md
new file mode 100644
index 0000000..fedc747
--- /dev/null
+++ b/specs/03-third-fourth-phase.md
@@ -0,0 +1,26 @@
+# Building the Phase 3 and Phase 4
+
+You have to build the Phase 3 and Phase 4 of the phases.md file.
+
+## Instruction
+
+You are a professional and experienced software developer with years of experience. Write professional and production-ready code and professional code logics and best-practices. think like a 100x engineer. do same as what he would do. follow proper typescript. Do not break anything. Build everything written in the phase.
+
+## What to DO.
+
+write production-ready code and professional code and best-practices. Follow professional code guidelines.Create a minimal aesthetic professional and popping out UI and UX that follows modern design principles and professional design hirarchy and strictly follow the DESIGN.md file for the design theme and layout and everything.
+
+## What not to do.
+
+Dont use hardcoded talwind colors.
+
+## Context
+
+- AGENTS.md
+- docs/API.md
+- docs/PROJECT.md
+- README.md
+- phases.md
+- DESIGN.md
+
+these all files have all the context related to project backend and full guides.
\ No newline at end of file
diff --git a/specs/04-fifth-sixth-phase.md b/specs/04-fifth-sixth-phase.md
new file mode 100644
index 0000000..b2901cc
--- /dev/null
+++ b/specs/04-fifth-sixth-phase.md
@@ -0,0 +1,26 @@
+# Building the Phase 5 and Phase 6
+
+You have to build the Phase 5 and Phase 6 of the phases.md file.
+
+## Instruction
+
+You are a professional and experienced software developer with years of experience. Write professional and production-ready code and professional code logics and best-practices. think like a 100x engineer. do same as what he would do. follow proper typescript. Do not break anything. Build everything written in the phase.
+
+## What to DO.
+
+write production-ready code and professional code and best-practices. Follow professional code guidelines.Create a minimal aesthetic professional and popping out UI and UX that follows modern design principles and professional design hirarchy and strictly follow the DESIGN.md file for the design theme and layout and everything.
+
+## What not to do.
+
+Dont use hardcoded talwind colors.
+
+## Context
+
+- AGENTS.md
+- docs/API.md
+- docs/PROJECT.md
+- README.md
+- phases.md
+- DESIGN.md
+
+these all files have all the context related to project backend and full guides.
\ No newline at end of file
diff --git a/specs/qr-caching-time-limited.md b/specs/qr-caching-time-limited.md
new file mode 100644
index 0000000..e8c9d87
--- /dev/null
+++ b/specs/qr-caching-time-limited.md
@@ -0,0 +1,109 @@
+# QR Code Caching & Time-Limited QR — Implementation Spec
+
+## Problem
+
+The current QR code endpoint (`GET /api/urls/:code/qr`) generates the QR on-the-fly every request. Two issues:
+
+1. **No caching** — every download re-renders the same QR, wasting CPU. The QR encodes the **destination URL** (not the short URL), so if the destination hasn't changed, the image is identical each time.
+2. **QR encodes destination URL directly** — scans bypass linkify's redirect entirely. This means:
+ - Visit analytics are **not recorded** from QR scans
+ - Password-protected links are **bypassed**
+ - Expired/deleted links still resolve (the QR points to the destination, not the short URL)
+ - Scheduled activation is ignored
+
+## Approach
+
+### 1. Change what the QR encodes
+
+Switch from encoding the **destination URL** to encoding the **short URL** (`http://localhost:3000/aB3xK9m`).
+
+**Why:** Scanning the short URL means every request goes through the redirect handler, which:
+- Records visit analytics
+- Checks password protection
+- Checks expiry (`expiresAt`) — the QR is naturally time-limited by the link's own TTL
+- Checks scheduled activation (`activeAt`)
+- Allows bot blocking, soft-delete enforcement, etc.
+
+**In the controller** (`qr.controller.ts`), instead of:
+```ts
+const urlRow = await resolveUrl(code)
+const targetUrl = urlRow.url
+```
+Use:
+```ts
+const targetUrl = `${BASE_URL}/${code}` // the short URL
+```
+(BASE_URL being the configured `BASE_URL` from env or `http://localhost:3000`)
+
+### 2. Cache the QR in the database
+
+Add a `qrCode` column to the `short_urls` table (or a separate `qr_codes` table keyed by `code + format + logo_hash`).
+
+**Schema option A — column on short_urls:**
+```sql
+ALTER TABLE short_urls ADD COLUMN qr_code_png bytea;
+ALTER TABLE short_urls ADD COLUMN qr_code_svg text;
+ALTER TABLE short_urls ADD COLUMN qr_logo_url text;
+ALTER TABLE short_urls ADD COLUMN qr_updated_at timestamptz;
+```
+
+**Schema option B — separate table (more flexible):**
+```sql
+CREATE TABLE qr_codes (
+ id serial PRIMARY KEY,
+ code text NOT NULL REFERENCES short_urls(code) ON DELETE CASCADE,
+ format text NOT NULL, -- 'png' or 'svg'
+ logo_url text,
+ data bytea NOT NULL, -- PNG binary or SVG text stored as bytes
+ created_at timestamptz DEFAULT now(),
+ UNIQUE(code, format, COALESCE(logo_url, ''))
+);
+```
+
+Option B is cleaner — supports multiple formats without sparse columns, and the composite unique key prevents duplicates.
+
+**Controller flow becomes:**
+1. Parse `format` and `logo` from query params
+2. Compute lookup key: `(code, format, logo_url ?? '')`
+3. Check DB cache — if row exists, return `data` with appropriate Content-Type
+4. If not cached, generate QR, store in DB, return it
+
+### 3. Invalidation triggers
+
+The cached QR must be invalidated when the **short URL encoding** changes. The QR encodes `BASE_URL/code`, and `code` never changes (it's the identity). So for the basic approach (encoding the short URL), **no invalidation is ever needed** — the QR is immutable for a given `(code, format, logo)` combination.
+
+If you later decide to encode the destination URL or add metadata like title, then invalidation triggers would be:
+- `PATCH /api/urls/:code/settings` — if destination URL or expiry changed
+- `DELETE /api/urls/:code` — cascade delete the QR rows
+- `DELETE /api/urls/:code/purge` — cascade delete
+
+### 4. Time-limited QR via dedicated field (optional enhancement)
+
+If you want QR expiry **independent** of link expiry, add a field:
+
+```sql
+ALTER TABLE short_urls ADD COLUMN qr_expires_at timestamptz;
+```
+
+This allows scenarios like:
+- Link is permanent but QR for a printed flyer should expire after the campaign
+- QR for a conference talk expires after the event, but the underlying link stays active for registered attendees via a password
+
+Add `qrExpiresAt` to the URL settings form (Phase 5) and check it in the QR controller before serving.
+
+## Summary of Changes
+
+| File | Change |
+|---|---|
+| `controllers/qr.controller.ts` | Encode short URL not destination; add cache check + store |
+| `services/qr.service.ts` | No change (still generates buffer/string) |
+| `db/schema.ts` | Add `qr_codes` table (or columns on `short_urls`) |
+| `controllers/link.controller.ts` | Add invalidation on settings update (if needed) |
+| `services/url.services.ts` | Add `getQrCache` / `setQrCache` helpers |
+| `pages/UrlDetailPage.tsx` | No change needed (already fetches from API) |
+
+## Migration
+
+- Existing saved QRs (none, since nothing is saved) — no migration needed
+- After deploy, first request for each QR generates + caches it; subsequent requests return cached version
+- If switching to short-URL encoding, any previously distributed QRs (encoding destination URL) will continue to work — they just won't be tracked. Newly generated QRs will encode the short URL.