Compare commits
10 Commits
4dc5e534ba
...
b25222cc82
| Author | SHA1 | Date | |
|---|---|---|---|
| b25222cc82 | |||
| c675430fb9 | |||
| 15d874e79b | |||
| ac211f86b3 | |||
| 789e55e79d | |||
| 19e2c22111 | |||
| 56d393e127 | |||
| 0ccd26444e | |||
| 6dce383e38 | |||
| 527ee2f325 |
525
docs/spec.md
525
docs/spec.md
@@ -1,283 +1,394 @@
|
|||||||
# QR-First URL Shortener SaaS (Designer + Short Links + Tracking)
|
# TrakQR - QR-First URL Shortener SaaS
|
||||||
|
|
||||||
Note: These specs are a draft and need review.
|
> Create branded short links and highly customizable QR codes, then track scans/clicks with actionable analytics.
|
||||||
|
|
||||||
## 0) Product Definition
|
## Product Overview
|
||||||
|
|
||||||
One-liner: Create branded short links and highly customizable QR codes, then track scans/clicks with actionable analytics.
|
**Primary users:**
|
||||||
|
|
||||||
Primary users:
|
|
||||||
- Solo creators / small businesses
|
- Solo creators / small businesses
|
||||||
- Marketing teams in SMB/PME
|
- Marketing teams in SMB/PME
|
||||||
- Agencies managing multiple clients
|
- Agencies managing multiple clients
|
||||||
|
|
||||||
Core value: Beautiful QR designs + brandable short domains + trustworthy tracking in one place.
|
**Core value:** Beautiful QR designs + brandable short domains + trustworthy tracking in one place.
|
||||||
|
|
||||||
## 1) MVP Scope (what ships first)
|
---
|
||||||
|
|
||||||
### 1.1 User Capabilities (MVP)
|
## 1. MVP Features (Implemented)
|
||||||
|
|
||||||
Auth & Account
|
### 1.1 Authentication & Account
|
||||||
- Sign up / sign in (email + password; optional SSO later)
|
|
||||||
- Email verification
|
|
||||||
- Password reset
|
|
||||||
- Basic account settings
|
|
||||||
|
|
||||||
Projects / Workspaces
|
| Feature | Status | Notes |
|
||||||
- Default workspace per user
|
|---------|--------|-------|
|
||||||
- Create “Projects” to organize links/QRs (e.g., “Restaurant menus”, “Flyers Q1”)
|
| Email + password signup | ✅ | With auto-created default workspace |
|
||||||
|
| Email verification | ✅ | Token-based, resend support |
|
||||||
|
| Login with JWT | ✅ | Rate-limited |
|
||||||
|
| Password reset | ✅ | Email-based token flow |
|
||||||
|
| Profile management | ✅ | Update email, change password |
|
||||||
|
| Delete account | ✅ | With password confirmation |
|
||||||
|
| SSO | ⏳ | Deferred to post-MVP |
|
||||||
|
|
||||||
Short Link Creation
|
### 1.2 Workspaces & Projects
|
||||||
- Create short link: https://d.om/abc123 or custom slug …/menu
|
|
||||||
- Destination URL validation
|
|
||||||
- Optional UTM builder (preset templates)
|
|
||||||
- Enable/disable link
|
|
||||||
- Expiration date (optional)
|
|
||||||
- Password protection (optional) — may be “Pro” if you want
|
|
||||||
|
|
||||||
QR Code Designer
|
| Feature | Status | Notes |
|
||||||
- Generate QR from a short link (default) or direct URL
|
|---------|--------|-------|
|
||||||
- Styling:
|
| Default workspace on signup | ✅ | Auto-created |
|
||||||
- Colors (foreground/background)
|
| Multiple workspaces | ✅ | Based on plan limits |
|
||||||
- Error correction level (L/M/Q/H)
|
| Workspace CRUD | ✅ | Create, update, delete |
|
||||||
- Quiet zone padding
|
| Projects for organization | ✅ | Full CRUD with descriptions |
|
||||||
- Shape presets (modules/eyes) (start with a few presets)
|
| Workspace switcher UI | ✅ | With create/manage modals |
|
||||||
- Center logo upload (PNG/SVG) with size + margin controls
|
|
||||||
- Export:
|
|
||||||
- PNG and SVG
|
|
||||||
- Size presets (e.g., 256/512/1024/2048)
|
|
||||||
- Print-ready options (e.g., “high contrast” toggle)
|
|
||||||
|
|
||||||
Tracking & Analytics (MVP)
|
### 1.3 Short Link Management
|
||||||
- Track events: click (short link) and scan (QR)
|
|
||||||
- Dashboard:
|
|
||||||
- Total events, uniques, last 24h / 7d / 30d
|
|
||||||
- Time series
|
|
||||||
- Top referrers (for clicks)
|
|
||||||
- Geo (country) and device (desktop/mobile) high-level
|
|
||||||
- Per-link analytics and per-QR analytics
|
|
||||||
|
|
||||||
Basic Admin
|
| Feature | Status | Notes |
|
||||||
- Subscription status
|
|---------|--------|-------|
|
||||||
- Usage quotas (links/QRs/events)
|
| Create short link | ✅ | Custom or auto-generated slug |
|
||||||
|
| Destination URL validation | ✅ | URL format validation |
|
||||||
|
| UTM builder | ✅ | Presets for Google, Facebook, Email, Social |
|
||||||
|
| Enable/disable link | ✅ | Status field (Active/Disabled) |
|
||||||
|
| Expiration date | ✅ | Optional datetime |
|
||||||
|
| Password protection | ✅ | Optional, with POST endpoint for auth |
|
||||||
|
| Soft delete + restore | ✅ | Trash view with restore |
|
||||||
|
| Bulk import | ✅ | CSV-style paste with titles |
|
||||||
|
| URL allowlist/denylist | ⏳ | Deferred - abuse prevention |
|
||||||
|
|
||||||
## 2) Non-Goals for MVP (explicitly out)
|
### 1.4 QR Code Designer
|
||||||
|
|
||||||
- Team roles/permissions (RBAC) beyond “owner”
|
| Feature | Status | Notes |
|
||||||
- A/B routing, smart rules, rotation, geo routing
|
|---------|--------|-------|
|
||||||
- Deep campaign automation
|
| Generate from short link | ✅ | Required link association |
|
||||||
- Enterprise SSO, SCIM
|
| Foreground/background colors | ✅ | Hex color pickers |
|
||||||
- Offline QR scan tracking (impossible without network in most cases)
|
| Error correction levels | ✅ | L/M/Q/H options |
|
||||||
|
| Quiet zone padding | ✅ | Configurable |
|
||||||
|
| Module shapes | ✅ | Square, Rounded, Dots |
|
||||||
|
| Eye shapes | ✅ | Square, Rounded, Circle |
|
||||||
|
| Style presets | ✅ | 6 built-in presets |
|
||||||
|
| Logo upload | ✅ | PNG/JPG, select from assets or upload new |
|
||||||
|
| Logo size controls | ⚠️ | Fixed 20% - user controls deferred |
|
||||||
|
| Live preview | ✅ | Real-time updates |
|
||||||
|
| Export PNG | ✅ | Configurable size (256-2048px) |
|
||||||
|
| Export SVG | ✅ | Vector output |
|
||||||
|
| Print-ready options | ⏳ | High contrast toggle deferred |
|
||||||
|
| Scan attribution | ✅ | Exports include `?qr={id}` param |
|
||||||
|
|
||||||
## 3) Plans & Monetization (recommended)
|
### 1.5 Tracking & Analytics
|
||||||
|
|
||||||
Free
|
| Feature | Status | Notes |
|
||||||
- 1 workspace
|
|---------|--------|-------|
|
||||||
- 25 short links
|
| Click events | ✅ | From redirect endpoint |
|
||||||
- 25 QR designs
|
| Scan events | ✅ | When `?qr=` param present |
|
||||||
- 10k events/month
|
| Async event logging | ✅ | Non-blocking, fire-and-forget |
|
||||||
- 1 custom QR logo upload (or allow unlimited but watermark exports)
|
| IP hashing | ✅ | SHA256 with daily salt |
|
||||||
|
| Dedupe (30-min window) | ✅ | Prevents duplicate counts |
|
||||||
|
| User agent parsing | ✅ | Device type detection |
|
||||||
|
| GeoIP lookup | ✅ | MaxMind GeoIP2 integration |
|
||||||
|
| Workspace analytics | ✅ | Totals, time series, breakdowns |
|
||||||
|
| Per-link analytics | ✅ | Individual link stats |
|
||||||
|
| Per-QR analytics | ✅ | Individual QR stats |
|
||||||
|
| Time filters | ✅ | 24h, 7d, 30d |
|
||||||
|
| Custom date range | ⚠️ | Backend ready, frontend UI needed |
|
||||||
|
| Referrer breakdown | ✅ | Top referrers list |
|
||||||
|
| Device breakdown | ✅ | Desktop/Mobile/Tablet |
|
||||||
|
| Country breakdown | ✅ | With flags and names |
|
||||||
|
| Monthly IP salt rotation | ⏳ | Deferred - privacy enhancement |
|
||||||
|
| Event retention config | ⏳ | Deferred - per-plan cleanup |
|
||||||
|
|
||||||
Pro (individual/SMB)
|
### 1.6 Domain Management
|
||||||
- Custom domains (1–3)
|
|
||||||
- Higher limits
|
|
||||||
- No watermark
|
|
||||||
- UTM templates
|
|
||||||
- Expiring links / password links (if Pro)
|
|
||||||
|
|
||||||
Business
|
| Feature | Status | Notes |
|
||||||
- Multiple workspaces
|
|---------|--------|-------|
|
||||||
- Team seats (later)
|
| Add custom domain | ✅ | Pro/Business plans |
|
||||||
- Higher retention and export presets
|
| DNS TXT verification | ✅ | Token-based |
|
||||||
|
| CNAME setup instructions | ✅ | UI guide |
|
||||||
|
| Domain status tracking | ✅ | Pending → Verified |
|
||||||
|
| Delete domain | ✅ | With warning |
|
||||||
|
|
||||||
## 4) Core Entities (Data Model)
|
### 1.7 Asset Management
|
||||||
|
|
||||||
### 4.1 Entities
|
| Feature | Status | Notes |
|
||||||
|
|---------|--------|-------|
|
||||||
|
| Upload assets | ✅ | For QR logos |
|
||||||
|
| List workspace assets | ✅ | Gallery view |
|
||||||
|
| Delete assets | ✅ | With cleanup |
|
||||||
|
| Public asset URL | ✅ | For rendering |
|
||||||
|
|
||||||
|
### 1.8 Plans & Billing
|
||||||
|
|
||||||
|
| Feature | Status | Notes |
|
||||||
|
|---------|--------|-------|
|
||||||
|
| Plan tiers (Free/Pro/Business) | ✅ | Configured limits |
|
||||||
|
| Usage tracking | ✅ | Links, QRs, domains, events |
|
||||||
|
| Plan limits enforcement | ✅ | In create endpoints |
|
||||||
|
| Stripe checkout | ✅ | Session-based |
|
||||||
|
| Stripe customer portal | ✅ | Manage subscription |
|
||||||
|
| Webhook handling | ✅ | Subscription events |
|
||||||
|
| Billing UI | ✅ | Plan comparison, upgrade flow |
|
||||||
|
|
||||||
|
### 1.9 API Keys
|
||||||
|
|
||||||
|
| Feature | Status | Notes |
|
||||||
|
|---------|--------|-------|
|
||||||
|
| Create API key | ✅ | With name and expiry |
|
||||||
|
| List API keys | ✅ | Shows prefix, last used |
|
||||||
|
| Delete API key | ✅ | Revoke access |
|
||||||
|
| API key authentication | ⏳ | Middleware needed |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Plan Limits
|
||||||
|
|
||||||
|
| Feature | Free | Pro | Business |
|
||||||
|
|---------|------|-----|----------|
|
||||||
|
| Workspaces | 1 | 5 | Unlimited |
|
||||||
|
| Links per workspace | 50 | 5,000 | Unlimited |
|
||||||
|
| QR codes per workspace | 25 | 1,000 | Unlimited |
|
||||||
|
| Custom domains | 0 | 3 | Unlimited |
|
||||||
|
| Events per month | 10,000 | 100,000 | Unlimited |
|
||||||
|
| Custom domains feature | ❌ | ✅ | ✅ |
|
||||||
|
| Password protection | ❌ | ✅ | ✅ |
|
||||||
|
| Analytics | ✅ | ✅ | ✅ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Data Model
|
||||||
|
|
||||||
|
### Core Entities
|
||||||
|
|
||||||
|
```
|
||||||
User
|
User
|
||||||
- id, email, password_hash, verified_at, created_at
|
├── id, email, password_hash
|
||||||
|
├── is_email_verified, created_at
|
||||||
|
└── Relations: Workspaces, EmailVerificationTokens, PasswordResetTokens
|
||||||
|
|
||||||
Workspace
|
Workspace
|
||||||
- id, owner_user_id, name, plan, created_at
|
├── id, owner_user_id, name, plan
|
||||||
|
├── created_at
|
||||||
|
└── Relations: Projects, ShortLinks, QRCodeDesigns, Domains, Assets, Events, ApiKeys
|
||||||
|
|
||||||
Project
|
Project
|
||||||
- id, workspace_id, name, created_at
|
├── id, workspace_id, name, description
|
||||||
|
└── created_at
|
||||||
|
|
||||||
Domain
|
Domain
|
||||||
- id, workspace_id, hostname, status (pending/verified/active), verification_token, created_at
|
├── id, workspace_id, hostname
|
||||||
|
├── status (Pending/Verified), verification_token
|
||||||
|
└── created_at
|
||||||
|
|
||||||
ShortLink
|
ShortLink
|
||||||
- id
|
├── id, workspace_id, project_id (nullable), domain_id (nullable)
|
||||||
- workspace_id, project_id (nullable)
|
├── slug, destination_url, title
|
||||||
- domain_id (nullable; else default platform domain)
|
├── status (Active/Disabled), expires_at, password_hash
|
||||||
- slug
|
├── click_count, is_deleted, deleted_at
|
||||||
- destination_url
|
└── created_at, updated_at
|
||||||
- title (nullable)
|
|
||||||
- status (active/disabled)
|
|
||||||
- expires_at (nullable)
|
|
||||||
- password_hash (nullable)
|
|
||||||
- created_at, updated_at
|
|
||||||
|
|
||||||
QRCodeDesign
|
QRCodeDesign
|
||||||
- id
|
├── id, workspace_id, project_id (nullable), link_id
|
||||||
- workspace_id, project_id (nullable)
|
├── name, style_json, logo_asset_id (nullable)
|
||||||
- shortlink_id (nullable; recommended default)
|
└── created_at, updated_at
|
||||||
- style_json (colors, shapes, ecc level, etc.)
|
|
||||||
- logo_asset_id (nullable)
|
|
||||||
- created_at, updated_at
|
|
||||||
|
|
||||||
Event
|
Event
|
||||||
- id (or bigint)
|
├── id, workspace_id, link_id, qr_code_id (nullable)
|
||||||
- workspace_id
|
├── type (Click/Scan), timestamp
|
||||||
- shortlink_id
|
├── ip_hash, user_agent, referrer
|
||||||
- qrcode_id (nullable but strongly recommended to tag scans)
|
├── country_code, device_type, dedupe_key
|
||||||
- type: click | scan
|
└── (partitioned by month for scale)
|
||||||
- ts
|
|
||||||
- ip_hash (privacy-safe)
|
|
||||||
- user_agent
|
|
||||||
- referrer
|
|
||||||
- country_code (nullable)
|
|
||||||
- device_type (nullable)
|
|
||||||
- dedupe_key (nullable)
|
|
||||||
- raw_json (optional for debug, or drop)
|
|
||||||
|
|
||||||
Asset
|
Asset
|
||||||
- id, workspace_id, type (logo), storage_key, mime, size, created_at
|
├── id, workspace_id, filename, storage_key
|
||||||
|
├── content_type, size_bytes
|
||||||
|
└── created_at
|
||||||
|
|
||||||
### 4.2 Key Design Choice
|
ApiKey
|
||||||
|
├── id, workspace_id, name, key_hash, key_prefix
|
||||||
|
├── scopes, expires_at, last_used_at, is_active
|
||||||
|
└── created_at
|
||||||
|
|
||||||
How do we distinguish “scan” vs “click”?
|
EmailVerificationToken
|
||||||
|
├── id, user_id, token, expires_at, used_at
|
||||||
|
└── created_at
|
||||||
|
|
||||||
When exporting a QR, embed a URL like:
|
PasswordResetToken
|
||||||
https://d.om/s/abc123?qr=<qrcode_id>
|
├── id, user_id, token, expires_at, used_at
|
||||||
|
└── created_at
|
||||||
|
```
|
||||||
|
|
||||||
The redirect endpoint records scan when it detects qr=<id>, then redirects to destination, which will also produce a click unless you decide “scan implies click” and record only one.
|
---
|
||||||
|
|
||||||
Recommendation: record one event per redirect request:
|
## 4. System Architecture
|
||||||
- If qr present → type=scan
|
|
||||||
- Else → type=click
|
|
||||||
|
|
||||||
## 5) System Behavior (Routes & Flows)
|
### Components
|
||||||
|
|
||||||
### 5.1 Public Redirect
|
```
|
||||||
|
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||||
|
│ Vue 3 SPA │────▶│ ASP.NET Core │────▶│ PostgreSQL │
|
||||||
|
│ + Pinia │ │ FastEndpoints │ │ │
|
||||||
|
└─────────────────┘ └─────────────────┘ └─────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────┐
|
||||||
|
│ External APIs │
|
||||||
|
│ - Stripe │
|
||||||
|
│ - SMTP │
|
||||||
|
│ - MaxMind │
|
||||||
|
└─────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
GET /{slug}
|
### Key Design Decisions
|
||||||
|
|
||||||
Resolve domain + slug → short link
|
1. **Vertical Slice Architecture**: Features organized in `Features/{Feature}/` folders
|
||||||
|
2. **FastEndpoints**: Lightweight alternative to MVC controllers
|
||||||
|
3. **Pinia State Management**: Centralized frontend state with persistence
|
||||||
|
4. **Non-blocking Event Logging**: Fire-and-forget to not slow redirects
|
||||||
|
5. **Soft Delete**: Links can be restored from trash
|
||||||
|
6. **Scan Attribution**: QR exports embed `?qr={id}` for tracking
|
||||||
|
|
||||||
Validate:
|
### Public Redirect Flow
|
||||||
- exists
|
|
||||||
- active
|
|
||||||
- not expired
|
|
||||||
- if password-protected → show password page
|
|
||||||
|
|
||||||
Log event (scan/click)
|
```
|
||||||
|
GET /{slug}?qr={id}
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────┐
|
||||||
|
│ 1. Resolve domain + slug → link │
|
||||||
|
│ 2. Validate: exists, active, !expired│
|
||||||
|
│ 3. If password → return 401 │
|
||||||
|
│ 4. Log event async (scan if ?qr=) │
|
||||||
|
│ 5. Return 302 redirect │
|
||||||
|
└─────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
Redirect 301/302 (configurable later; MVP use 302)
|
---
|
||||||
|
|
||||||
### 5.2 QR Export
|
## 5. API Endpoints
|
||||||
|
|
||||||
QR code is generated from:
|
### Authentication
|
||||||
|
- `POST /auth/register` - Create account
|
||||||
|
- `POST /auth/login` - Get JWT token
|
||||||
|
- `POST /auth/forgot` - Request password reset
|
||||||
|
- `POST /auth/reset` - Reset password with token
|
||||||
|
- `POST /auth/verify-email` - Verify email
|
||||||
|
- `POST /auth/resend-verification` - Resend verification email
|
||||||
|
- `GET /auth/profile` - Get current user
|
||||||
|
- `PUT /auth/profile` - Update profile
|
||||||
|
- `POST /auth/change-password` - Change password
|
||||||
|
- `DELETE /auth/account` - Delete account
|
||||||
|
|
||||||
Redirect URL including qrcode id: https://{domain}/{slug}?qr={qrcode_id}
|
### Workspaces & Projects
|
||||||
|
- `GET/POST /workspaces` - List/Create workspaces
|
||||||
|
- `GET/PUT/DELETE /workspaces/{id}` - Workspace operations
|
||||||
|
- `GET/POST /workspaces/{id}/projects` - List/Create projects
|
||||||
|
- `GET/PUT/DELETE /workspaces/{id}/projects/{pid}` - Project operations
|
||||||
|
|
||||||
## 6) Functional Requirements (MVP checklist)
|
### Links
|
||||||
|
- `GET/POST /workspaces/{id}/links` - List/Create links
|
||||||
|
- `POST /workspaces/{id}/links/bulk` - Bulk create
|
||||||
|
- `GET/PUT/DELETE /workspaces/{id}/links/{lid}` - Link operations
|
||||||
|
- `POST /workspaces/{id}/links/{lid}/restore` - Restore deleted
|
||||||
|
- `GET /workspaces/{id}/links/{lid}/analytics` - Link analytics
|
||||||
|
|
||||||
Link Management
|
### QR Codes
|
||||||
- Create, edit, disable, delete (soft delete preferred)
|
- `GET/POST /workspaces/{id}/qrcodes` - List/Create QR codes
|
||||||
- Slug uniqueness per domain
|
- `GET/PUT/DELETE /workspaces/{id}/qrcodes/{qid}` - QR operations
|
||||||
- Auto-slug generator (base62)
|
- `GET /workspaces/{id}/qrcodes/{qid}/preview` - Get preview (data URL)
|
||||||
- Destination URL allowlist/denylist (prevent abuse)
|
- `GET /workspaces/{id}/qrcodes/{qid}/export` - Export PNG/SVG
|
||||||
|
- `GET /workspaces/{id}/qrcodes/{qid}/analytics` - QR analytics
|
||||||
|
|
||||||
QR Designer
|
### Domains & Assets
|
||||||
- Live preview
|
- `GET/POST /workspaces/{id}/domains` - List/Add domains
|
||||||
- Save design
|
- `DELETE /workspaces/{id}/domains/{did}` - Delete domain
|
||||||
- Export SVG/PNG
|
- `POST /workspaces/{id}/domains/{did}/verify` - Verify domain
|
||||||
- Logo upload with validation (size/mime)
|
- `GET/POST /workspaces/{id}/assets` - List/Upload assets
|
||||||
|
- `DELETE /workspaces/{id}/assets/{aid}` - Delete asset
|
||||||
|
- `GET /assets/{storageKey}` - Public asset URL
|
||||||
|
|
||||||
Analytics
|
### Analytics & Usage
|
||||||
- Views for:
|
- `GET /workspaces/{id}/analytics` - Workspace analytics
|
||||||
- Workspace overview
|
- `GET /usage` - Usage stats and limits
|
||||||
- Project overview
|
|
||||||
- Per short link
|
|
||||||
- Per QR design
|
|
||||||
- Time filters: 24h / 7d / 30d / custom range
|
|
||||||
- Unique definition:
|
|
||||||
- Unique per day per link based on ip_hash + UA hash (privacy-safe and approximate)
|
|
||||||
|
|
||||||
## 7) Non-Functional Requirements
|
### Billing
|
||||||
|
- `POST /billing/checkout` - Create Stripe checkout
|
||||||
|
- `POST /billing/portal` - Create Stripe portal session
|
||||||
|
- `GET /workspaces/{id}/subscription` - Get subscription
|
||||||
|
- `POST /billing/webhook` - Stripe webhooks
|
||||||
|
|
||||||
Performance
|
### API Keys
|
||||||
- Redirect endpoint P95 < 100ms (excluding DNS/TLS)
|
- `GET/POST /workspaces/{id}/api-keys` - List/Create keys
|
||||||
- Event write must not block redirect (use async queue if possible)
|
- `DELETE /workspaces/{id}/api-keys/{kid}` - Delete key
|
||||||
|
|
||||||
Availability
|
### Public
|
||||||
- Redirect is the critical path; should stay up even if dashboard is down
|
- `GET /{slug}` - Redirect to destination
|
||||||
- Graceful degradation: if analytics store is down, still redirect
|
- `POST /{slug}` - Redirect with password
|
||||||
|
|
||||||
Security
|
---
|
||||||
- Rate limit public endpoints
|
|
||||||
- Abuse prevention: phishing/malware reporting flow (later), basic filters now
|
|
||||||
- Domain verification to prevent takeover
|
|
||||||
- Strict CSP on app pages
|
|
||||||
|
|
||||||
Privacy & Compliance (Canada / Quebec friendly baseline)
|
## 6. UI Pages
|
||||||
- Avoid storing raw IP; store hashed IP with rotating salt (e.g., monthly)
|
|
||||||
- Provide retention configuration per plan (e.g., 30/180/365 days)
|
|
||||||
|
|
||||||
## 8) Architecture (pragmatic MVP)
|
| Page | Route | Status |
|
||||||
|
|------|-------|--------|
|
||||||
|
| Landing | `/` | ✅ |
|
||||||
|
| Login | `/login` | ✅ |
|
||||||
|
| Register | `/register` | ✅ |
|
||||||
|
| Forgot Password | `/forgot-password` | ✅ |
|
||||||
|
| Reset Password | `/reset-password` | ✅ |
|
||||||
|
| Verify Email | `/verify-email` | ✅ |
|
||||||
|
| Dashboard | `/dashboard` | ✅ |
|
||||||
|
| Links List | `/links` | ✅ |
|
||||||
|
| Link Detail | `/links/:id` | ✅ |
|
||||||
|
| QR Codes List | `/qrcodes` | ✅ |
|
||||||
|
| QR Designer | `/qrcodes/new`, `/qrcodes/:id` | ✅ |
|
||||||
|
| QR Analytics | `/qrcodes/:id/analytics` | ✅ |
|
||||||
|
| Analytics | `/analytics` | ✅ |
|
||||||
|
| Projects | `/projects` | ✅ |
|
||||||
|
| Domains | `/domains` | ✅ |
|
||||||
|
| Settings | `/settings` | ✅ |
|
||||||
|
| Billing | `/billing` | ✅ |
|
||||||
|
|
||||||
Components
|
---
|
||||||
- Web App: dashboard + designer (Vue/React)
|
|
||||||
- API: CRUD for links/qr/projects/domains, analytics queries
|
|
||||||
- Redirect Edge: fastest path for /{slug} (can be same API initially)
|
|
||||||
|
|
||||||
Storage
|
## 7. Security & Performance
|
||||||
- PostgreSQL for core entities
|
|
||||||
- Analytics:
|
|
||||||
- MVP: PostgreSQL events table partitioned by month
|
|
||||||
- Later: ClickHouse/BigQuery for scale
|
|
||||||
|
|
||||||
Background Jobs
|
### Implemented
|
||||||
- Domain verification checks
|
- [x] JWT authentication with expiry
|
||||||
- Event enrichment (geo/device parsing)
|
- [x] Rate limiting on auth endpoints (10 req/min)
|
||||||
- Cleanup & retention tasks
|
- [x] Rate limiting on redirect endpoint (100 req/min)
|
||||||
|
- [x] Password hashing with BCrypt
|
||||||
|
- [x] IP hashing for privacy
|
||||||
|
- [x] CORS configuration
|
||||||
|
- [x] Global exception handling
|
||||||
|
- [x] Input validation with FluentValidation
|
||||||
|
- [x] Ownership verification on all endpoints
|
||||||
|
|
||||||
## 9) API Surface (minimal)
|
### Deferred
|
||||||
|
- [ ] Strict CSP headers
|
||||||
|
- [ ] Monthly rotating IP salt
|
||||||
|
- [ ] URL allowlist/denylist
|
||||||
|
- [ ] Abuse reporting flow
|
||||||
|
|
||||||
- POST /auth/register|login|forgot|reset
|
---
|
||||||
- GET/POST /workspaces
|
|
||||||
- GET/POST /projects
|
|
||||||
- GET/POST /links
|
|
||||||
- GET/POST /qrcodes
|
|
||||||
- POST /domains + verification status
|
|
||||||
- GET /analytics/overview
|
|
||||||
- GET /analytics/link/{id}
|
|
||||||
- GET /analytics/qrcode/{id}
|
|
||||||
|
|
||||||
## 10) UI Pages (MVP)
|
## 8. Remaining Work
|
||||||
|
|
||||||
- Login / Register / Reset
|
### High Priority
|
||||||
- Workspace switcher
|
1. **API Key Authentication Middleware** - Enable programmatic access
|
||||||
- Projects list
|
2. **Bulk Create Plan Limits** - Check limits in bulk endpoint
|
||||||
- Links list + create/edit
|
3. **Custom Date Range UI** - Date picker for analytics
|
||||||
- QR designer (create/edit) with preview
|
|
||||||
- Analytics dashboard (overview + per link + per QR)
|
|
||||||
- Domains page (add/verify)
|
|
||||||
|
|
||||||
## 11) Pricing/Quotas Enforcement
|
### Medium Priority
|
||||||
|
4. **Background Jobs** - Domain verification polling, event cleanup
|
||||||
|
5. **Logo Size Controls** - User-adjustable logo size/margin
|
||||||
|
6. **Additional Tests** - Auth, billing, API key endpoints
|
||||||
|
|
||||||
Enforce at API level:
|
### Lower Priority
|
||||||
- max links, max QR codes, max events/month, max custom domains
|
7. **Print-Ready QR** - High contrast mode
|
||||||
|
8. **Analytics Export** - CSV/JSON download
|
||||||
|
9. **Strict CSP** - Security headers
|
||||||
|
10. **IP Salt Rotation** - Monthly rotation for privacy
|
||||||
|
|
||||||
Stripe integration later; MVP can be “manual Pro” toggle or Stripe from day 1 if you want.
|
---
|
||||||
|
|
||||||
## 12) Implementation Notes (key decisions)
|
## 9. Non-Goals (Post-MVP)
|
||||||
|
|
||||||
- Use one redirect URL as canonical; QR adds ?qr= for attribution.
|
- Team roles/permissions (RBAC)
|
||||||
- Event logging should be non-blocking:
|
- A/B routing, smart rules, geo routing
|
||||||
- MVP: write to DB async (background queue) or “fire-and-forget” with retry
|
- Deep campaign automation
|
||||||
- Plan for domain verification:
|
- Enterprise SSO, SCIM
|
||||||
- Require DNS TXT record or CNAME to verify ownership
|
- Offline QR scan tracking
|
||||||
- Short link collision:
|
|
||||||
- Slug uniqueness per domain enforced in DB
|
|
||||||
|
|||||||
@@ -269,9 +269,9 @@
|
|||||||
- [ ] Middleware to authenticate requests using API keys
|
- [ ] Middleware to authenticate requests using API keys
|
||||||
- [ ] Scope validation (read, write, admin)
|
- [ ] Scope validation (read, write, admin)
|
||||||
|
|
||||||
2. **Plan Limits Enforcement**
|
2. **Plan Limits in Bulk Operations**
|
||||||
- [ ] Integrate `IPlanLimitsService.CanCreate*` checks in create endpoints
|
- [ ] Add plan limits check in `BulkCreateLinksEndpoint`
|
||||||
- [ ] Return 403 with upgrade message when limit reached
|
- Plan limits already enforced in: CreateLink, CreateQRCode, CreateWorkspace, AddDomain
|
||||||
|
|
||||||
3. **Custom Date Range UI**
|
3. **Custom Date Range UI**
|
||||||
- [ ] Add date picker to analytics pages
|
- [ ] Add date picker to analytics pages
|
||||||
|
|||||||
408
docs/test-plan.md
Normal file
408
docs/test-plan.md
Normal file
@@ -0,0 +1,408 @@
|
|||||||
|
# TrakQR Test Plan
|
||||||
|
|
||||||
|
> Manual test plan for validating all features before release.
|
||||||
|
- ## Test Plan Overview
|
||||||
|
|
||||||
|
| Item | Details |
|
||||||
|
|------|---------|
|
||||||
|
| **Application** | TrakQR - QR-First URL Shortener SaaS |
|
||||||
|
| **Version** | MVP |
|
||||||
|
| **Test Type** | Functional / End-to-End |
|
||||||
|
| **Environment** | Development (`localhost:5173` + `localhost:42001`) |
|
||||||
|
- ## How to Use This Document
|
||||||
|
|
||||||
|
1. Work through each section sequentially
|
||||||
|
2. Check the box `[x]` when a test passes
|
||||||
|
3. Mark `[F]` for failures and add notes
|
||||||
|
4. Mark `[S]` for skipped tests with reason
|
||||||
|
|
||||||
|
---
|
||||||
|
- ## 1. Authentication
|
||||||
|
- ### 1.1 Registration
|
||||||
|
|
||||||
|
| # | Test Case | Steps | Expected Result | Status |
|
||||||
|
|---|-----------|-------|-----------------|--------|
|
||||||
|
| 1.1.1 | Register with valid email | 1. Go to `/register`<br>2. Enter valid email and password (8+ chars)<br>3. Click Register | Account created, redirected to dashboard, default workspace created | [ ] |
|
||||||
|
| 1.1.2 | Register with invalid email | 1. Go to `/register`<br>2. Enter invalid email format<br>3. Click Register | Error message shown, no account created | [ ] |
|
||||||
|
| 1.1.3 | Register with short password | 1. Go to `/register`<br>2. Enter valid email, password < 8 chars<br>3. Click Register | Error message about password length | [ ] |
|
||||||
|
| 1.1.4 | Register with existing email | 1. Register a new account<br>2. Try to register again with same email | Error message about email already in use | [ ] |
|
||||||
|
| 1.1.5 | Registration rate limiting | 1. Attempt to register 15+ times in 1 minute | Rate limit error after ~10 attempts | [ ] |
|
||||||
|
- ### 1.2 Login
|
||||||
|
|
||||||
|
| # | Test Case | Steps | Expected Result | Status |
|
||||||
|
|---|-----------|-------|-----------------|--------|
|
||||||
|
| 1.2.1 | Login with valid credentials | 1. Go to `/login`<br>2. Enter registered email/password<br>3. Click Login | Logged in, redirected to dashboard | [ ] |
|
||||||
|
| 1.2.2 | Login with wrong password | 1. Go to `/login`<br>2. Enter valid email, wrong password<br>3. Click Login | Error message, not logged in | [ ] |
|
||||||
|
| 1.2.3 | Login with non-existent email | 1. Go to `/login`<br>2. Enter unregistered email<br>3. Click Login | Error message (generic, no email enumeration) | [ ] |
|
||||||
|
| 1.2.4 | Login rate limiting | 1. Attempt failed logins 15+ times | Rate limit error after ~10 attempts | [ ] |
|
||||||
|
| 1.2.5 | Redirect after login | 1. Try to access `/links` while logged out<br>2. Get redirected to login<br>3. Login successfully | Redirected back to `/links` | [ ] |
|
||||||
|
- ### 1.3 Password Reset
|
||||||
|
|
||||||
|
| # | Test Case | Steps | Expected Result | Status |
|
||||||
|
|---|-----------|-------|-----------------|--------|
|
||||||
|
| 1.3.1 | Request password reset | 1. Go to `/forgot-password`<br>2. Enter registered email<br>3. Click Submit | Success message shown (check console for email in dev) | [ ] |
|
||||||
|
| 1.3.2 | Reset with valid token | 1. Get reset token from email/console<br>2. Go to `/reset-password?token=xxx`<br>3. Enter new password | Password changed, can login with new password | [ ] |
|
||||||
|
| 1.3.3 | Reset with invalid token | 1. Go to `/reset-password?token=invalid`<br>2. Enter new password | Error message about invalid/expired token | [ ] |
|
||||||
|
| 1.3.4 | Reset with expired token | 1. Wait for token to expire (or modify DB)<br>2. Try to use token | Error message about expired token | [ ] |
|
||||||
|
- ### 1.4 Email Verification
|
||||||
|
|
||||||
|
| # | Test Case | Steps | Expected Result | Status |
|
||||||
|
|----------------|-------|-----------------|--------|
|
||||||
|
| 1.4.1 | Verify with valid token | 1. Register new account<br>2. Get verification token from console<br>3. Go to `/verify-email?token=xxx` | Email verified, success message | [ ] |
|
||||||
|
| 1.4.2 | Verify with invalid token | 1. Go to `/verify-email?token=invalid` | Error message about invalid token | [ ] |
|
||||||
|
| 1.4.3 | Resend verification | 1. Login with unverified account<br>2. Go to Settings<br>3. Click "Resend verification" | New email sent (check console) | [ ] |
|
||||||
|
- ### 1.5 Logout
|
||||||
|
|
||||||
|
| # | Test Case | Steps | Expected Result | Status |
|
||||||
|
|---|-----------|-------|-----------------|--------|
|
||||||
|
| 1.5.1 | Logout | 1. Login<br>2. Click Logout in sidebar | Logged out, redirected to login, protected routes inaccessible | [ ] |
|
||||||
|
| 1.5.2 | Session persistence | 1. Login<br>2. Close browser<br>3. Reopen and go to app | Still logged in (token in localStorage) | [ ] |
|
||||||
|
|
||||||
|
---
|
||||||
|
- ## 2. Workspaces
|
||||||
|
- ### 2.1 Workspace Management
|
||||||
|
|
||||||
|
| # | Test Case | Steps | Expected Result | Status |
|
||||||
|
|---|-----------|-------|-----------------|--------|
|
||||||
|
| 2.1.1 | Default workspace exists | 1. Register new account<br>2. Check workspace selector | Default workspace present | [ ] |
|
||||||
|
| 2.1.2 | Create workspace | 1. Click + button in workspace selector<br>2. Enter name<br>3. Click Create | New workspace created, switched to it | [ ] |
|
||||||
|
| 2.1.3 | Switch workspace | 1. Have 2+ workspaces<br>2. Select different workspace from dropdown | Workspace switched, data refreshed | [ ] |
|
||||||
|
| 2.1.4 | Update workspace name | 1. Click settings icon in workspace selector<br>2. Change name<br>3. Save | Name updated | [ ] |
|
||||||
|
| 2.1.5 | Delete workspace | 1. Have 2+ workspaces<br>2. Open workspace settings<br>3. Click Delete | Workspace deleted, switched to another | [ ] |
|
||||||
|
| 2.1.6 | Cannot delete only workspace | 1. Have only 1 workspace<br>2. Try to delete it | Delete button disabled or error shown | [ ] |
|
||||||
|
| 2.1.7 | Workspace persistence | 1. Switch to workspace B<br>2. Refresh page | Still on workspace B (localStorage) | [ ] |
|
||||||
|
|
||||||
|
---
|
||||||
|
- ## 3. Projects
|
||||||
|
- ### 3.1 Project CRUD
|
||||||
|
|
||||||
|
| # | Test Case | Steps | Expected Result | Status |
|
||||||
|
|---|-----------|-------|-----------------|--------|
|
||||||
|
| 3.1.1 | View projects page | 1. Go to `/projects` | Projects list shown (or empty state) | [ ] |
|
||||||
|
| 3.1.2 | Create project | 1. Click "New Project"<br>2. Enter name and description<br>3. Save | Project created, appears in list | [ ] |
|
||||||
|
| 3.1.3 | Edit project | 1. Click edit on a project<br>2. Change name/description<br>3. Save | Project updated | [ ] |
|
||||||
|
| 3.1.4 | Delete project | 1. Click delete on a project<br>2. Confirm deletion | Project removed from list | [ ] |
|
||||||
|
| 3.1.5 | Project shows link count | 1. Create project<br>2. Create links assigned to project<br>3. View projects | Link count displayed correctly | [ ] |
|
||||||
|
|
||||||
|
---
|
||||||
|
- ## 4. Short Links
|
||||||
|
- ### 4.1 Link Creation
|
||||||
|
|
||||||
|
| # | Test Case | Steps | Expected Result | Status |
|
||||||
|
|---|-----------|-------|-----------------|--------|
|
||||||
|
| 4.1.1 | Create link with auto slug | 1. Click "Create Link"<br>2. Enter destination URL only<br>3. Save | Link created with auto-generated slug | [ ] |
|
||||||
|
| 4.1.2 | Create link with custom slug | 1. Click "Create Link"<br>2. Enter URL and custom slug<br>3. Save | Link created with custom slug | [ ] |
|
||||||
|
| 4.1.3 | Create link with title | 1. Create link with title filled in | Title shown in list | [ ] |
|
||||||
|
| 4.1.4 | Create link with expiration | 1. Create link with expiration date set | Link shows expiration, stops working after date | [ ] |
|
||||||
|
| 4.1.5 | Create link with password | 1. Create link with password set | Link requires password to access | [ ] |
|
||||||
|
| 4.1.6 | Invalid destination URL | 1. Try to create link with invalid URL | Error message shown | [ ] |
|
||||||
|
| 4.1.7 | Duplicate slug | 1. Create link with slug "test"<br>2. Try to create another with same slug | Error message about duplicate | [ ] |
|
||||||
|
- ### 4.2 Link Management
|
||||||
|
|
||||||
|
| # | Test Case | Steps | Expected Result | Status |
|
||||||
|
|---|-----------|-------|-----------------|--------|
|
||||||
|
| 4.2.1 | View links list | 1. Go to `/links` | Links listed with short URL, destination, clicks | [ ] |
|
||||||
|
| 4.2.2 | Copy short URL | 1. Click copy icon on a link | URL copied to clipboard | [ ] |
|
||||||
|
| 4.2.3 | Edit link | 1. Click edit on a link<br>2. Change destination URL<br>3. Save | Link updated, redirect goes to new URL | [ ] |
|
||||||
|
| 4.2.4 | Delete link (soft) | 1. Click delete on a link<br>2. Confirm | Link moved to trash | [ ] |
|
||||||
|
| 4.2.5 | View trash | 1. Click "Trash" toggle | Deleted links shown | [ ] |
|
||||||
|
| 4.2.6 | Restore link | 1. View trash<br>2. Click restore on a link | Link restored, works again | [ ] |
|
||||||
|
| 4.2.7 | Disable link | 1. Edit link<br>2. Set status to Disabled<br>3. Save | Link returns 404/disabled message | [ ] |
|
||||||
|
- ### 4.3 Bulk Import
|
||||||
|
|
||||||
|
| # | Test Case | Steps | Expected Result | Status |
|
||||||
|
|---|-----------|-------|-----------------|--------|
|
||||||
|
| 4.3.1 | Bulk import URLs | 1. Click "Bulk Import"<br>2. Paste multiple URLs (one per line)<br>3. Import | All links created | [ ] |
|
||||||
|
| 4.3.2 | Bulk import with titles | 1. Paste URLs with titles: `https://example.com, My Title`<br>2. Import | Links created with titles | [ ] |
|
||||||
|
| 4.3.3 | Bulk import with invalid URLs | 1. Include some invalid URLs in list<br>2. Import | Valid URLs imported, errors shown for invalid | [ ] |
|
||||||
|
- ### 4.4 UTM Builder
|
||||||
|
|
||||||
|
| # | Test Case | Steps | Expected Result | Status |
|
||||||
|
|---|-----------|-------|-----------------|--------|
|
||||||
|
| 4.4.1 | Add UTM parameters | 1. Create link<br>2. Expand UTM builder<br>3. Fill in source, medium, campaign | UTM params appended to destination | [ ] |
|
||||||
|
| 4.4.2 | UTM presets | 1. Click "Google Ads" preset | Fields populated with Google preset values | [ ] |
|
||||||
|
| 4.4.3 | UTM preview | 1. Fill in UTM fields | Preview shows full URL with params | [ ] |
|
||||||
|
|
||||||
|
---
|
||||||
|
- ## 5. Redirect
|
||||||
|
- ### 5.1 Public Redirect
|
||||||
|
|
||||||
|
| # | Test Case | Steps | Expected Result | Status |
|
||||||
|
|---|-----------|-------|-----------------|--------|
|
||||||
|
| 5.1.1 | Basic redirect | 1. Create link to `https://example.com`<br>2. Visit short URL | Redirected to example.com | [ ] |
|
||||||
|
| 5.1.2 | Non-existent slug | 1. Visit `/{random-slug}` | 404 error page | [ ] |
|
||||||
|
| 5.1.3 | Disabled link | 1. Disable a link<br>2. Visit its short URL | Error message (link disabled) | [ ] |
|
||||||
|
| 5.1.4 | Expired link | 1. Create link with past expiration<br>2. Visit short URL | Error message (link expired) | [ ] |
|
||||||
|
| 5.1.5 | Password-protected link | 1. Create password-protected link<br>2. Visit short URL | Password prompt shown | [ ] |
|
||||||
|
| 5.1.6 | Password entry | 1. Visit password-protected link<br>2. Enter correct password | Redirected to destination | [ ] |
|
||||||
|
| 5.1.7 | Wrong password | 1. Visit password-protected link<br>2. Enter wrong password | Error, stays on password page | [ ] |
|
||||||
|
| 5.1.8 | Redirect rate limiting | 1. Hit redirect endpoint 100+ times rapidly | Rate limited after threshold | [ ] |
|
||||||
|
|
||||||
|
---
|
||||||
|
- ## 6. QR Codes
|
||||||
|
- ### 6.1 QR Creation
|
||||||
|
|
||||||
|
| # | Test Case | Steps | Expected Result | Status |
|
||||||
|
|---|-----------|-------|-----------------|--------|
|
||||||
|
| 6.1.1 | Create basic QR | 1. Go to `/qrcodes/new`<br>2. Select a link<br>3. Save | QR code created with default style | [ ] |
|
||||||
|
| 6.1.2 | QR requires link | 1. Go to `/qrcodes/new`<br>2. Try to save without link | Error message | [ ] |
|
||||||
|
| 6.1.3 | QR name | 1. Create QR with custom name | Name shown in list | [ ] |
|
||||||
|
- ### 6.2 QR Designer
|
||||||
|
|
||||||
|
| # | Test Case | Steps | Expected Result | Status |
|
||||||
|
|---|-----------|-------|-----------------|--------|
|
||||||
|
| 6.2.1 | Change foreground color | 1. Edit QR<br>2. Change foreground color<br>3. View preview | QR updates with new color | [ ] |
|
||||||
|
| 6.2.2 | Change background color | 1. Change background color | QR updates with new background | [ ] |
|
||||||
|
| 6.2.3 | Module shapes | 1. Try Square, Rounded, Dots shapes | Each shape renders correctly | [ ] |
|
||||||
|
| 6.2.4 | Eye shapes | 1. Try Square, Rounded, Circle eye shapes | Each eye shape renders correctly | [ ] |
|
||||||
|
| 6.2.5 | Error correction levels | 1. Try L, M, Q, H levels | QR generates with different densities | [ ] |
|
||||||
|
| 6.2.6 | Quiet zone | 1. Adjust quiet zone size | Padding around QR changes | [ ] |
|
||||||
|
| 6.2.7 | Style presets | 1. Click each of 6 presets | Styles applied correctly | [ ] |
|
||||||
|
| 6.2.8 | Upload logo | 1. Click logo upload<br>2. Select image file | Logo appears in center of QR | [ ] |
|
||||||
|
| 6.2.9 | Select existing logo | 1. Have uploaded assets<br>2. Select from asset gallery | Logo applied from gallery | [ ] |
|
||||||
|
| 6.2.10 | Remove logo | 1. Have QR with logo<br>2. Remove logo | QR renders without logo | [ ] |
|
||||||
|
- ### 6.3 QR Export
|
||||||
|
|
||||||
|
| # | Test Case | Steps | Expected Result | Status |
|
||||||
|
|---|-----------|-------|-----------------|--------|
|
||||||
|
| 6.3.1 | Export PNG | 1. View QR<br>2. Click "Download PNG" | PNG file downloaded | [ ] |
|
||||||
|
| 6.3.2 | Export SVG | 1. Click "Download SVG" | SVG file downloaded | [ ] |
|
||||||
|
| 6.3.3 | PNG has correct size | 1. Export PNG<br>2. Check dimensions | Matches selected size (256/512/1024) | [ ] |
|
||||||
|
| 6.3.4 | QR is scannable | 1. Export QR<br>2. Scan with phone | Opens correct short URL | [ ] |
|
||||||
|
| 6.3.5 | QR with logo is scannable | 1. Add logo to QR<br>2. Export and scan | Still scans correctly | [ ] |
|
||||||
|
- ### 6.4 QR List
|
||||||
|
|
||||||
|
| # | Test Case | Steps | Expected Result | Status |
|
||||||
|
|---|-----------|-------|-----------------|--------|
|
||||||
|
| 6.4.1 | View QR list | 1. Go to `/qrcodes` | List shows QR previews | [ ] |
|
||||||
|
| 6.4.2 | QR preview thumbnails | 1. View list | Each QR shows thumbnail preview | [ ] |
|
||||||
|
| 6.4.3 | Delete QR | 1. Click delete on QR<br>2. Confirm | QR removed from list | [ ] |
|
||||||
|
| 6.4.4 | Edit QR | 1. Click edit on QR | Opens designer with saved settings | [ ] |
|
||||||
|
|
||||||
|
---
|
||||||
|
- ## 7. Analytics
|
||||||
|
- ### 7.1 Event Tracking
|
||||||
|
|
||||||
|
| # | Test Case | Steps | Expected Result | Status |
|
||||||
|
|---|-----------|-------|-----------------|--------|
|
||||||
|
| 7.1.1 | Click event recorded | 1. Create link<br>2. Visit short URL<br>3. Check analytics | Click count increased | [ ] |
|
||||||
|
| 7.1.2 | Scan event recorded | 1. Create QR<br>2. Export and scan QR<br>3. Check QR analytics | Scan count increased | [ ] |
|
||||||
|
| 7.1.3 | Unique visitor tracking | 1. Visit same link multiple times quickly<br>2. Check analytics | Only 1 unique visitor (dedupe) | [ ] |
|
||||||
|
| 7.1.4 | Device detection | 1. Visit from mobile device<br>2. Check device breakdown | Mobile device recorded | [ ] |
|
||||||
|
| 7.1.5 | Referrer tracking | 1. Visit link from a webpage<br>2. Check referrer breakdown | Referrer domain recorded | [ ] |
|
||||||
|
| 7.1.6 | Country detection | 1. Visit link<br>2. Check geo breakdown | Country detected (if GeoIP configured) | [ ] |
|
||||||
|
- ### 7.2 Dashboard Analytics
|
||||||
|
|
||||||
|
| # | Test Case | Steps | Expected Result | Status |
|
||||||
|
|---|-----------|-------|-----------------|--------|
|
||||||
|
| 7.2.1 | View dashboard | 1. Go to `/dashboard` | Stats cards, chart, breakdowns shown | [ ] |
|
||||||
|
| 7.2.2 | Total clicks | 1. Check total clicks card | Matches sum of all link clicks | [ ] |
|
||||||
|
| 7.2.3 | Time series chart | 1. View activity chart | Shows clicks/scans over time | [ ] |
|
||||||
|
| 7.2.4 | Top links | 1. View top links section | Most clicked links shown | [ ] |
|
||||||
|
| 7.2.5 | Device breakdown | 1. View device breakdown | Desktop/Mobile/Tablet shown | [ ] |
|
||||||
|
| 7.2.6 | Referrer breakdown | 1. View referrer breakdown | Top referrers listed | [ ] |
|
||||||
|
| 7.2.7 | Country breakdown | 1. View geo breakdown | Countries with flags shown | [ ] |
|
||||||
|
- ### 7.3 Period Filters
|
||||||
|
|
||||||
|
| # | Test Case | Steps | Expected Result | Status |
|
||||||
|
|---|-----------|-------|-----------------|--------|
|
||||||
|
| 7.3.1 | 24h filter | 1. Click "24h" period | Data filtered to last 24 hours | [ ] |
|
||||||
|
| 7.3.2 | 7d filter | 1. Click "7d" period | Data filtered to last 7 days | [ ] |
|
||||||
|
| 7.3.3 | 30d filter | 1. Click "30d" period | Data filtered to last 30 days | [ ] |
|
||||||
|
- ### 7.4 Per-Link Analytics
|
||||||
|
|
||||||
|
| # | Test Case | Steps | Expected Result | Status |
|
||||||
|
|---|-----------|-------|-----------------|--------|
|
||||||
|
| 7.4.1 | View link detail | 1. Go to `/links/{id}` | Link analytics shown | [ ] |
|
||||||
|
| 7.4.2 | Link-specific stats | 1. View link detail | Stats match only that link's data | [ ] |
|
||||||
|
- ### 7.5 Per-QR Analytics
|
||||||
|
|
||||||
|
| # | Test Case | Steps | Expected Result | Status |
|
||||||
|
|---|-----------|-------|-----------------|--------|
|
||||||
|
| 7.5.1 | View QR analytics | 1. Go to `/qrcodes/{id}/analytics` | QR scan stats shown | [ ] |
|
||||||
|
| 7.5.2 | QR-specific stats | 1. View QR detail | Stats match only that QR's scans | [ ] |
|
||||||
|
|
||||||
|
---
|
||||||
|
- ## 8. Domains
|
||||||
|
- ### 8.1 Domain Management
|
||||||
|
|
||||||
|
| # | Test Case | Steps | Expected Result | Status |
|
||||||
|
|---|-----------|-------|-----------------|--------|
|
||||||
|
| 8.1.1 | View domains page | 1. Go to `/domains` | Domains list or upgrade prompt (Free plan) | [ ] |
|
||||||
|
| 8.1.2 | Add domain (Pro+) | 1. Click "Add Domain"<br>2. Enter hostname<br>3. Submit | Domain added with Pending status | [ ] |
|
||||||
|
| 8.1.3 | Verification instructions | 1. Add new domain | TXT record and CNAME instructions shown | [ ] |
|
||||||
|
| 8.1.4 | Copy verification token | 1. Click copy on verification token | Token copied to clipboard | [ ] |
|
||||||
|
| 8.1.5 | Verify domain | 1. Add DNS records<br>2. Click "Verify" | Domain status changes to Verified | [ ] |
|
||||||
|
| 8.1.6 | Delete domain | 1. Click delete on domain<br>2. Confirm | Domain removed | [ ] |
|
||||||
|
| 8.1.7 | Free plan restriction | 1. Be on Free plan<br>2. Try to add domain | Upgrade prompt shown | [ ] |
|
||||||
|
|
||||||
|
---
|
||||||
|
- ## 9. Settings
|
||||||
|
- ### 9.1 Profile
|
||||||
|
|
||||||
|
| # | Test Case | Steps | Expected Result | Status |
|
||||||
|
|---|-----------|-------|-----------------|--------|
|
||||||
|
| 9.1.1 | View profile | 1. Go to `/settings` | Current email shown | [ ] |
|
||||||
|
| 9.1.2 | Update email | 1. Change email<br>2. Save | Email updated | [ ] |
|
||||||
|
| 9.1.3 | Verification warning | 1. Have unverified email | Warning shown with resend link | [ ] |
|
||||||
|
- ### 9.2 Password Change
|
||||||
|
|
||||||
|
| # | Test Case | Steps | Expected Result | Status |
|
||||||
|
|---|-----------|-------|-----------------|--------|
|
||||||
|
| 9.2.1 | Change password | 1. Enter current password<br>2. Enter new password twice<br>3. Submit | Password changed | [ ] |
|
||||||
|
| 9.2.2 | Wrong current password | 1. Enter wrong current password<br>2. Submit | Error message | [ ] |
|
||||||
|
| 9.2.3 | Passwords don't match | 1. Enter different passwords<br>2. Submit | Error message | [ ] |
|
||||||
|
- ### 9.3 API Keys
|
||||||
|
|
||||||
|
| # | Test Case | Steps | Expected Result | Status |
|
||||||
|
|---|-----------|-------|-----------------|--------|
|
||||||
|
| 9.3.1 | View API keys | 1. Scroll to API Keys section | List of keys (or empty state) | [ ] |
|
||||||
|
| 9.3.2 | Create API key | 1. Click "Create Key"<br>2. Enter name<br>3. Create | Key created, full key shown once | [ ] |
|
||||||
|
| 9.3.3 | Copy API key | 1. Create key<br>2. Click copy | Key copied to clipboard | [ ] |
|
||||||
|
| 9.3.4 | Key with expiry | 1. Create key with 30-day expiry | Expiry date shown | [ ] |
|
||||||
|
| 9.3.5 | Delete API key | 1. Click delete on a key<br>2. Confirm | Key removed | [ ] |
|
||||||
|
| 9.3.6 | Key prefix shown | 1. View existing keys | Only prefix visible (e.g., `tq_abc...`) | [ ] |
|
||||||
|
- ### 9.4 Danger Zone
|
||||||
|
|
||||||
|
| # | Test Case | Steps | Expected Result | Status |
|
||||||
|
|---|-----------|-------|-----------------|--------|
|
||||||
|
| 9.4.1 | Delete account prompt | 1. Click "Delete Account" | Confirmation modal with password | [ ] |
|
||||||
|
| 9.4.2 | Delete account | 1. Enter password<br>2. Confirm delete | Account deleted, logged out | [ ] |
|
||||||
|
| 9.4.3 | Wrong password on delete | 1. Enter wrong password | Error, account not deleted | [ ] |
|
||||||
|
|
||||||
|
---
|
||||||
|
- ## 10. Billing
|
||||||
|
- ### 10.1 Plan Display
|
||||||
|
|
||||||
|
| # | Test Case | Steps | Expected Result | Status |
|
||||||
|
|---|-----------|-------|-----------------|--------|
|
||||||
|
| 10.1.1 | View billing page | 1. Go to `/billing` | Current plan and features shown | [ ] |
|
||||||
|
| 10.1.2 | Plan comparison | 1. View billing page | Free/Pro/Business plans compared | [ ] |
|
||||||
|
| 10.1.3 | Current plan highlighted | 1. View billing page | Current plan marked as "Current" | [ ] |
|
||||||
|
- ### 10.2 Stripe Integration
|
||||||
|
|
||||||
|
| # | Test Case | Steps | Expected Result | Status |
|
||||||
|
|---|-----------|-------|-----------------|--------|
|
||||||
|
| 10.2.1 | Upgrade to Pro | 1. Click "Upgrade" on Pro<br>2. Complete Stripe checkout | Plan upgraded | [ ] |
|
||||||
|
| 10.2.2 | Manage subscription | 1. Click "Manage Subscription"<br>2. Stripe portal opens | Can update payment, cancel | [ ] |
|
||||||
|
| 10.2.3 | Cancel subscription | 1. Cancel in Stripe portal | Plan downgrades at period end | [ ] |
|
||||||
|
|
||||||
|
---
|
||||||
|
- ## 11. Plan Limits
|
||||||
|
- ### 11.1 Free Plan Limits
|
||||||
|
|
||||||
|
| # | Test Case | Steps | Expected Result | Status |
|
||||||
|
|---|-----------|-------|-----------------|--------|
|
||||||
|
| 11.1.1 | Workspace limit | 1. Be on Free plan<br>2. Try to create 2nd workspace | Error: limit reached | [ ] |
|
||||||
|
| 11.1.2 | Link limit | 1. Create 50 links<br>2. Try to create 51st | Error: limit reached | [ ] |
|
||||||
|
| 11.1.3 | QR code limit | 1. Create 25 QR codes<br>2. Try to create 26th | Error: limit reached | [ ] |
|
||||||
|
| 11.1.4 | No custom domains | 1. Try to add domain on Free | Upgrade prompt | [ ] |
|
||||||
|
- ### 11.2 Usage Display
|
||||||
|
|
||||||
|
| # | Test Case | Steps | Expected Result | Status |
|
||||||
|
|---|-----------|-------|-----------------|--------|
|
||||||
|
| 11.2.1 | View usage | 1. Check billing or dashboard | Usage stats shown (links, QRs, events) | [ ] |
|
||||||
|
|
||||||
|
---
|
||||||
|
- ## 12. UI/UX
|
||||||
|
- ### 12.1 Navigation
|
||||||
|
|
||||||
|
| # | Test Case | Steps | Expected Result | Status |
|
||||||
|
|---|-----------|-------|-----------------|--------|
|
||||||
|
| 12.1.1 | Sidebar navigation | 1. Click each nav item | Correct page loads | [ ] |
|
||||||
|
| 12.1.2 | Active nav highlight | 1. Navigate to different pages | Current page highlighted in sidebar | [ ] |
|
||||||
|
| 12.1.3 | Mobile responsive | 1. Resize to mobile width | Layout adapts correctly | [ ] |
|
||||||
|
- ### 12.2 Loading States
|
||||||
|
|
||||||
|
| # | Test Case | Steps | Expected Result | Status |
|
||||||
|
|---|-----------|-------|-----------------|--------|
|
||||||
|
| 12.2.1 | Loading indicators | 1. Navigate to data-heavy page | Loading indicator shown while fetching | [ ] |
|
||||||
|
| 12.2.2 | Button loading states | 1. Submit a form | Button shows loading state | [ ] |
|
||||||
|
- ### 12.3 Error Handling
|
||||||
|
|
||||||
|
| # | Test Case | Steps | Expected Result | Status |
|
||||||
|
|---|-----------|-------|-----------------|--------|
|
||||||
|
| 12.3.1 | API errors shown | 1. Trigger an API error | User-friendly error message | [ ] |
|
||||||
|
| 12.3.2 | Network error | 1. Disable network<br>2. Try an action | Appropriate error message | [ ] |
|
||||||
|
| 12.3.3 | 401 redirect | 1. Clear token from localStorage<br>2. Try protected action | Redirected to login | [ ] |
|
||||||
|
- ### 12.4 Empty States
|
||||||
|
|
||||||
|
| # | Test Case | Steps | Expected Result | Status |
|
||||||
|
|---|-----------|-------|-----------------|--------|
|
||||||
|
| 12.4.1 | No links | 1. View links with none created | Empty state with CTA | [ ] |
|
||||||
|
| 12.4.2 | No QR codes | 1. View QR codes with none created | Empty state with CTA | [ ] |
|
||||||
|
| 12.4.3 | No projects | 1. View projects with none created | Empty state with CTA | [ ] |
|
||||||
|
| 12.4.4 | No analytics data | 1. View analytics with no events | Empty state message | [ ] |
|
||||||
|
|
||||||
|
---
|
||||||
|
- ## 13. Security
|
||||||
|
- ### 13.1 Authentication
|
||||||
|
|
||||||
|
| # | Test Case | Steps | Expected Result | Status |
|
||||||
|
|---|-----------|-------|-----------------|--------|
|
||||||
|
| 13.1.1 | Protected routes | 1. Log out<br>2. Try to access `/dashboard` | Redirected to login | [ ] |
|
||||||
|
| 13.1.2 | JWT expiry | 1. Wait for token to expire<br>2. Try an action | Redirected to login | [ ] |
|
||||||
|
| 13.1.3 | Cross-workspace access | 1. Try to access another user's workspace ID | 404 or 403 error | [ ] |
|
||||||
|
- ### 13.2 Input Validation
|
||||||
|
|
||||||
|
| # | Test Case | Steps | Expected Result | Status |
|
||||||
|
|---|-----------|-------|-----------------|--------|
|
||||||
|
| 13.2.1 | XSS in link title | 1. Create link with `<script>` in title | Script not executed, text escaped | [ ] |
|
||||||
|
| 13.2.2 | SQL injection attempt | 1. Enter SQL in form fields | No SQL executed, handled safely | [ ] |
|
||||||
|
|
||||||
|
---
|
||||||
|
- ## Test Summary
|
||||||
|
|
||||||
|
| Section | Total Tests | Passed | Failed | Skipped |
|
||||||
|
|---------|-------------|--------|--------|---------|
|
||||||
|
| 1. Authentication | 16 | | | |
|
||||||
|
| 2. Workspaces | 7 | | | |
|
||||||
|
| 3. Projects | 5 | | | |
|
||||||
|
| 4. Short Links | 17 | | | |
|
||||||
|
| 5. Redirect | 8 | | | |
|
||||||
|
| 6. QR Codes | 17 | | | |
|
||||||
|
| 7. Analytics | 17 | | | |
|
||||||
|
| 8. Domains | 7 | | | |
|
||||||
|
| 9. Settings | 12 | | | |
|
||||||
|
| 10. Billing | 5 | | | |
|
||||||
|
| 11. Plan Limits | 5 | | | |
|
||||||
|
| 12. UI/UX | 11 | | | |
|
||||||
|
| 13. Security | 4 | | | |
|
||||||
|
| **TOTAL** | **131** | | | |
|
||||||
|
|
||||||
|
---
|
||||||
|
- ## Test Environment Setup
|
||||||
|
- ### Prerequisites
|
||||||
|
|
||||||
|
1. **Backend running**: `cd src/api && dotnet run`
|
||||||
|
2. **Frontend running**: `cd src/frontend && npm run dev`
|
||||||
|
3. **Database**: PostgreSQL with migrations applied
|
||||||
|
4. **Email**: Console output in development (check terminal)
|
||||||
|
5. **Stripe**: Test mode with test API keys (optional for billing tests)
|
||||||
|
6. **GeoIP**: MaxMind database configured (optional for geo tests)
|
||||||
|
- ### Test Accounts
|
||||||
|
|
||||||
|
Create these accounts before testing:
|
||||||
|
|
||||||
|
| Email | Password | Purpose |
|
||||||
|
|-------|----------|---------|
|
||||||
|
| `test@example.com` | `password123` | General testing |
|
||||||
|
| `pro@example.com` | `password123` | Pro plan testing |
|
||||||
|
| `free@example.com` | `password123` | Free limits testing |
|
||||||
|
|
||||||
|
---
|
||||||
|
- ## Notes
|
||||||
|
- Mark tests as `[F]` if they fail and add details below
|
||||||
|
- Mark tests as `[S]` if skipped with reason
|
||||||
|
- Re-run failed tests after fixes
|
||||||
|
- Update this document as features change
|
||||||
|
- ### Failed Tests Log
|
||||||
|
|
||||||
|
| Test # | Issue | Date | Fixed |
|
||||||
|
|--------|-------|------|-------|
|
||||||
|
| | | | |
|
||||||
|
- ### Skipped Tests Log
|
||||||
|
|
||||||
|
| Test # | Reason | Date |
|
||||||
|
|--------|--------|------|
|
||||||
|
| | | |
|
||||||
13
src/TrackApi/.idea/.idea.TrackQrApi/.idea/.gitignore
generated
vendored
Normal file
13
src/TrackApi/.idea/.idea.TrackQrApi/.idea/.gitignore
generated
vendored
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
# Default ignored files
|
||||||
|
/shelf/
|
||||||
|
/workspace.xml
|
||||||
|
# Rider ignored files
|
||||||
|
/projectSettingsUpdater.xml
|
||||||
|
/modules.xml
|
||||||
|
/contentModel.xml
|
||||||
|
/.idea.TrackQrApi.iml
|
||||||
|
# Editor-based HTTP Client requests
|
||||||
|
/httpRequests/
|
||||||
|
# Datasource local storage ignored files
|
||||||
|
/dataSources/
|
||||||
|
/dataSources.local.xml
|
||||||
1
src/TrackApi/.idea/.idea.TrackQrApi/.idea/.name
generated
Normal file
1
src/TrackApi/.idea/.idea.TrackQrApi/.idea/.name
generated
Normal file
@@ -0,0 +1 @@
|
|||||||
|
TrackQrApi
|
||||||
13
src/TrackApi/.idea/.idea.TrackQrApi/.idea/dataSources.xml
generated
Normal file
13
src/TrackApi/.idea/.idea.TrackQrApi/.idea/dataSources.xml
generated
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
|
||||||
|
<data-source source="LOCAL" name="trakqr@localhost" uuid="d4e0d8dc-9924-4b70-a2e0-ee27030702dd">
|
||||||
|
<driver-ref>postgresql</driver-ref>
|
||||||
|
<synchronize>true</synchronize>
|
||||||
|
<configured-by-url>true</configured-by-url>
|
||||||
|
<jdbc-driver>org.postgresql.Driver</jdbc-driver>
|
||||||
|
<jdbc-url>jdbc:postgresql://localhost:5400/trakqr?password=P%40ssword123%21&user=sa</jdbc-url>
|
||||||
|
<working-dir>$ProjectFileDir$</working-dir>
|
||||||
|
</data-source>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
6
src/TrackApi/.idea/.idea.TrackQrApi/.idea/db-forest-config.xml
generated
Normal file
6
src/TrackApi/.idea/.idea.TrackQrApi/.idea/db-forest-config.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="db-tree-configuration">
|
||||||
|
<option name="data" value="---------------------------------------- 1:0:d4e0d8dc-9924-4b70-a2e0-ee27030702dd " />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
4
src/TrackApi/.idea/.idea.TrackQrApi/.idea/encodings.xml
generated
Normal file
4
src/TrackApi/.idea/.idea.TrackQrApi/.idea/encodings.xml
generated
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="Encoding" addBOMForNewFiles="with BOM under Windows, with no BOM otherwise" />
|
||||||
|
</project>
|
||||||
8
src/TrackApi/.idea/.idea.TrackQrApi/.idea/indexLayout.xml
generated
Normal file
8
src/TrackApi/.idea/.idea.TrackQrApi/.idea/indexLayout.xml
generated
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="UserContentModel">
|
||||||
|
<attachedFolders />
|
||||||
|
<explicitIncludes />
|
||||||
|
<explicitExcludes />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
6
src/TrackApi/.idea/.idea.TrackQrApi/.idea/vcs.xml
generated
Normal file
6
src/TrackApi/.idea/.idea.TrackQrApi/.idea/vcs.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="VcsDirectoryMappings">
|
||||||
|
<mapping directory="$PROJECT_DIR$/../.." vcs="Git" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
@@ -1,36 +1,31 @@
|
|||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Net.Http.Headers;
|
using System.Net.Http.Headers;
|
||||||
using System.Net.Http.Json;
|
using System.Net.Http.Json;
|
||||||
using api.Features.Analytics.Common;
|
|
||||||
using api.Features.Auth.Common;
|
|
||||||
using api.Features.Links.Common;
|
|
||||||
using api.Features.Workspaces.Common;
|
|
||||||
using FluentAssertions;
|
using FluentAssertions;
|
||||||
using Microsoft.AspNetCore.Mvc.Testing;
|
using Microsoft.AspNetCore.Mvc.Testing;
|
||||||
|
using TrackQrApi.Features.Analytics.Common;
|
||||||
|
using TrackQrApi.Features.Auth.Common;
|
||||||
|
using TrackQrApi.Features.Links.Common;
|
||||||
|
using TrackQrApi.Features.Workspaces.Common;
|
||||||
|
|
||||||
namespace Api.Tests;
|
namespace TrackQrApi.Tests;
|
||||||
|
|
||||||
public class AnalyticsEndpointTests : IClassFixture<ApiWebApplicationFactory>
|
public class AnalyticsEndpointTests(
|
||||||
|
ApiWebApplicationFactory factory)
|
||||||
|
: IClassFixture<ApiWebApplicationFactory>
|
||||||
{
|
{
|
||||||
private readonly HttpClient _client;
|
private readonly HttpClient _client = factory.CreateClient();
|
||||||
private readonly HttpClient _noRedirectClient;
|
|
||||||
|
|
||||||
public AnalyticsEndpointTests(ApiWebApplicationFactory factory)
|
private readonly HttpClient _noRedirectClient = factory.CreateClient(new WebApplicationFactoryClientOptions
|
||||||
{
|
{
|
||||||
_client = factory.CreateClient();
|
AllowAutoRedirect = false
|
||||||
_noRedirectClient = factory.CreateClient(new WebApplicationFactoryClientOptions
|
});
|
||||||
{
|
|
||||||
AllowAutoRedirect = false
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<(string Token, Guid WorkspaceId)> SetupAuthAndWorkspaceAsync(string email)
|
private async Task<(string Token, Guid WorkspaceId)> SetupAuthAndWorkspaceAsync(string email)
|
||||||
{
|
{
|
||||||
var response = await _client.PostAsJsonAsync("/auth/register", new { Email = email, Password = "password123" });
|
var response = await _client.PostAsJsonAsync("/auth/register", new { Email = email, Password = "password123" });
|
||||||
if (response.StatusCode == HttpStatusCode.Conflict)
|
if (response.StatusCode == HttpStatusCode.Conflict)
|
||||||
{
|
|
||||||
response = await _client.PostAsJsonAsync("/auth/login", new { Email = email, Password = "password123" });
|
response = await _client.PostAsJsonAsync("/auth/login", new { Email = email, Password = "password123" });
|
||||||
}
|
|
||||||
var authResult = await response.Content.ReadFromJsonAsync<AuthResponse>();
|
var authResult = await response.Content.ReadFromJsonAsync<AuthResponse>();
|
||||||
var token = authResult!.Token;
|
var token = authResult!.Token;
|
||||||
|
|
||||||
@@ -1,20 +1,37 @@
|
|||||||
using api.Data;
|
|
||||||
using Microsoft.AspNetCore.Hosting;
|
using Microsoft.AspNetCore.Hosting;
|
||||||
using Microsoft.AspNetCore.Mvc.Testing;
|
using Microsoft.AspNetCore.Mvc.Testing;
|
||||||
using Microsoft.AspNetCore.TestHost;
|
using Microsoft.AspNetCore.TestHost;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Configuration;
|
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Testcontainers.PostgreSql;
|
using Testcontainers.PostgreSql;
|
||||||
|
using TrackQrApi.Data;
|
||||||
|
using TrackQrApi.Models;
|
||||||
|
|
||||||
namespace Api.Tests;
|
namespace TrackQrApi.Tests;
|
||||||
|
|
||||||
public sealed class ApiWebApplicationFactory : WebApplicationFactory<Program>, IAsyncLifetime
|
public sealed class ApiWebApplicationFactory : WebApplicationFactory<Program>, IAsyncLifetime
|
||||||
{
|
{
|
||||||
private readonly PostgreSqlContainer _postgres = new PostgreSqlBuilder("postgres:latest")
|
private readonly PostgreSqlContainer _postgres = new PostgreSqlBuilder("postgres:latest")
|
||||||
.Build();
|
.Build();
|
||||||
|
|
||||||
private bool _containerStarted = false;
|
private bool _containerStarted;
|
||||||
|
|
||||||
|
public async Task InitializeAsync()
|
||||||
|
{
|
||||||
|
// Ensure container is started (might already be started from ConfigureWebHost)
|
||||||
|
EnsureContainerStarted();
|
||||||
|
|
||||||
|
// Run migrations
|
||||||
|
using var scope = Services.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
await db.Database.MigrateAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public new async Task DisposeAsync()
|
||||||
|
{
|
||||||
|
await _postgres.DisposeAsync();
|
||||||
|
await base.DisposeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
private void EnsureContainerStarted()
|
private void EnsureContainerStarted()
|
||||||
{
|
{
|
||||||
@@ -47,40 +64,18 @@ public sealed class ApiWebApplicationFactory : WebApplicationFactory<Program>, I
|
|||||||
builder.ConfigureTestServices(services =>
|
builder.ConfigureTestServices(services =>
|
||||||
{
|
{
|
||||||
// Remove existing DbContext registration
|
// Remove existing DbContext registration
|
||||||
var descriptor = services.SingleOrDefault(
|
var descriptor = services.SingleOrDefault(d => d.ServiceType == typeof(DbContextOptions<AppDbContext>));
|
||||||
d => d.ServiceType == typeof(DbContextOptions<AppDbContext>));
|
|
||||||
|
|
||||||
if (descriptor != null)
|
if (descriptor != null) services.Remove(descriptor);
|
||||||
{
|
|
||||||
services.Remove(descriptor);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add DbContext with Testcontainers connection string
|
// Add DbContext with Testcontainers connection string
|
||||||
services.AddDbContext<AppDbContext>(options =>
|
services.AddDbContext<AppDbContext>(options =>
|
||||||
options.UseNpgsql(_postgres.GetConnectionString()));
|
options.UseNpgsql(_postgres.GetConnectionString()));
|
||||||
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task InitializeAsync()
|
|
||||||
{
|
|
||||||
// Ensure container is started (might already be started from ConfigureWebHost)
|
|
||||||
EnsureContainerStarted();
|
|
||||||
|
|
||||||
// Run migrations
|
|
||||||
using var scope = Services.CreateScope();
|
|
||||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
|
||||||
await db.Database.MigrateAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
public new async Task DisposeAsync()
|
|
||||||
{
|
|
||||||
await _postgres.DisposeAsync();
|
|
||||||
await base.DisposeAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Upgrades a workspace to Pro plan for testing features that require paid plans.
|
/// Upgrades a workspace to Pro plan for testing features that require paid plans.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task UpgradeWorkspaceToPro(Guid workspaceId)
|
public async Task UpgradeWorkspaceToPro(Guid workspaceId)
|
||||||
{
|
{
|
||||||
@@ -89,7 +84,7 @@ public sealed class ApiWebApplicationFactory : WebApplicationFactory<Program>, I
|
|||||||
var workspace = await db.Workspaces.FindAsync(workspaceId);
|
var workspace = await db.Workspaces.FindAsync(workspaceId);
|
||||||
if (workspace != null)
|
if (workspace != null)
|
||||||
{
|
{
|
||||||
workspace.Plan = api.Models.WorkspacePlan.Pro;
|
workspace.Plan = WorkspacePlan.Pro;
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,14 +1,15 @@
|
|||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Net.Http.Headers;
|
using System.Net.Http.Headers;
|
||||||
using System.Net.Http.Json;
|
using System.Net.Http.Json;
|
||||||
using api.Features.Auth.Common;
|
|
||||||
using api.Features.Assets.Common;
|
|
||||||
using api.Features.Workspaces.Common;
|
|
||||||
using FluentAssertions;
|
using FluentAssertions;
|
||||||
|
using TrackQrApi.Features.Assets.Common;
|
||||||
|
using TrackQrApi.Features.Auth.Common;
|
||||||
|
using TrackQrApi.Features.Workspaces.Common;
|
||||||
|
|
||||||
namespace Api.Tests;
|
namespace TrackQrApi.Tests;
|
||||||
|
|
||||||
public class AssetEndpointTests(ApiWebApplicationFactory factory)
|
public class AssetEndpointTests(
|
||||||
|
ApiWebApplicationFactory factory)
|
||||||
: IClassFixture<ApiWebApplicationFactory>
|
: IClassFixture<ApiWebApplicationFactory>
|
||||||
{
|
{
|
||||||
private readonly HttpClient _client = factory.CreateClient();
|
private readonly HttpClient _client = factory.CreateClient();
|
||||||
@@ -17,9 +18,7 @@ public class AssetEndpointTests(ApiWebApplicationFactory factory)
|
|||||||
{
|
{
|
||||||
var response = await _client.PostAsJsonAsync("/auth/register", new { Email = email, Password = "password123" });
|
var response = await _client.PostAsJsonAsync("/auth/register", new { Email = email, Password = "password123" });
|
||||||
if (response.StatusCode == HttpStatusCode.Conflict)
|
if (response.StatusCode == HttpStatusCode.Conflict)
|
||||||
{
|
|
||||||
response = await _client.PostAsJsonAsync("/auth/login", new { Email = email, Password = "password123" });
|
response = await _client.PostAsJsonAsync("/auth/login", new { Email = email, Password = "password123" });
|
||||||
}
|
|
||||||
var result = await response.Content.ReadFromJsonAsync<AuthResponse>();
|
var result = await response.Content.ReadFromJsonAsync<AuthResponse>();
|
||||||
var token = result!.Token;
|
var token = result!.Token;
|
||||||
|
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Net.Http.Json;
|
using System.Net.Http.Json;
|
||||||
using api.Features.Auth.Common;
|
|
||||||
using FluentAssertions;
|
using FluentAssertions;
|
||||||
|
using TrackQrApi.Features.Auth.Common;
|
||||||
|
|
||||||
namespace Api.Tests;
|
namespace TrackQrApi.Tests;
|
||||||
|
|
||||||
public class AuthControllerTests(ApiWebApplicationFactory factory)
|
public class AuthControllerTests(
|
||||||
|
ApiWebApplicationFactory factory)
|
||||||
: IClassFixture<ApiWebApplicationFactory>
|
: IClassFixture<ApiWebApplicationFactory>
|
||||||
{
|
{
|
||||||
private readonly HttpClient _client = factory.CreateClient();
|
private readonly HttpClient _client = factory.CreateClient();
|
||||||
@@ -1,25 +1,25 @@
|
|||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Net.Http.Headers;
|
using System.Net.Http.Headers;
|
||||||
using System.Net.Http.Json;
|
using System.Net.Http.Json;
|
||||||
using api.Features.Auth.Common;
|
|
||||||
using api.Features.Domains.Common;
|
|
||||||
using api.Features.Workspaces.Common;
|
|
||||||
using FluentAssertions;
|
using FluentAssertions;
|
||||||
|
using TrackQrApi.Features.Auth.Common;
|
||||||
|
using TrackQrApi.Features.Domains.Common;
|
||||||
|
using TrackQrApi.Features.Workspaces.Common;
|
||||||
|
|
||||||
namespace Api.Tests;
|
namespace TrackQrApi.Tests;
|
||||||
|
|
||||||
public class DomainEndpointTests(ApiWebApplicationFactory factory)
|
public class DomainEndpointTests(
|
||||||
|
ApiWebApplicationFactory factory)
|
||||||
: IClassFixture<ApiWebApplicationFactory>
|
: IClassFixture<ApiWebApplicationFactory>
|
||||||
{
|
{
|
||||||
private readonly HttpClient _client = factory.CreateClient();
|
private readonly HttpClient _client = factory.CreateClient();
|
||||||
|
|
||||||
private async Task<(string Token, Guid WorkspaceId)> GetAuthAndWorkspaceAsync(string email, bool upgradeToPro = true)
|
private async Task<(string Token, Guid WorkspaceId)> GetAuthAndWorkspaceAsync(string email,
|
||||||
|
bool upgradeToPro = true)
|
||||||
{
|
{
|
||||||
var response = await _client.PostAsJsonAsync("/auth/register", new { Email = email, Password = "password123" });
|
var response = await _client.PostAsJsonAsync("/auth/register", new { Email = email, Password = "password123" });
|
||||||
if (response.StatusCode == HttpStatusCode.Conflict)
|
if (response.StatusCode == HttpStatusCode.Conflict)
|
||||||
{
|
|
||||||
response = await _client.PostAsJsonAsync("/auth/login", new { Email = email, Password = "password123" });
|
response = await _client.PostAsJsonAsync("/auth/login", new { Email = email, Password = "password123" });
|
||||||
}
|
|
||||||
var result = await response.Content.ReadFromJsonAsync<AuthResponse>();
|
var result = await response.Content.ReadFromJsonAsync<AuthResponse>();
|
||||||
var token = result!.Token;
|
var token = result!.Token;
|
||||||
|
|
||||||
@@ -29,10 +29,7 @@ public class DomainEndpointTests(ApiWebApplicationFactory factory)
|
|||||||
var workspaceId = workspaces!.Workspaces.First().Id;
|
var workspaceId = workspaces!.Workspaces.First().Id;
|
||||||
|
|
||||||
// Upgrade to Pro plan for domain tests (Free plan doesn't allow custom domains)
|
// Upgrade to Pro plan for domain tests (Free plan doesn't allow custom domains)
|
||||||
if (upgradeToPro)
|
if (upgradeToPro) await factory.UpgradeWorkspaceToPro(workspaceId);
|
||||||
{
|
|
||||||
await factory.UpgradeWorkspaceToPro(workspaceId);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (token, workspaceId);
|
return (token, workspaceId);
|
||||||
}
|
}
|
||||||
@@ -45,7 +42,8 @@ public class DomainEndpointTests(ApiWebApplicationFactory factory)
|
|||||||
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var response = await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/domains", new { Hostname = "example.com" });
|
var response =
|
||||||
|
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/domains", new { Hostname = "example.com" });
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
response.StatusCode.Should().Be(HttpStatusCode.Created);
|
response.StatusCode.Should().Be(HttpStatusCode.Created);
|
||||||
@@ -81,7 +79,8 @@ public class DomainEndpointTests(ApiWebApplicationFactory factory)
|
|||||||
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/domains", new { Hostname = "duplicate.com" });
|
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/domains", new { Hostname = "duplicate.com" });
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var response = await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/domains", new { Hostname = "duplicate.com" });
|
var response =
|
||||||
|
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/domains", new { Hostname = "duplicate.com" });
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
response.StatusCode.Should().Be(HttpStatusCode.Conflict);
|
response.StatusCode.Should().Be(HttpStatusCode.Conflict);
|
||||||
@@ -113,7 +112,8 @@ public class DomainEndpointTests(ApiWebApplicationFactory factory)
|
|||||||
var (token, workspaceId) = await GetAuthAndWorkspaceAsync("get-domain@example.com");
|
var (token, workspaceId) = await GetAuthAndWorkspaceAsync("get-domain@example.com");
|
||||||
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||||
|
|
||||||
var createResponse = await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/domains", new { Hostname = "get-test.com" });
|
var createResponse =
|
||||||
|
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/domains", new { Hostname = "get-test.com" });
|
||||||
var created = await createResponse.Content.ReadFromJsonAsync<DomainResponse>();
|
var created = await createResponse.Content.ReadFromJsonAsync<DomainResponse>();
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
@@ -147,7 +147,8 @@ public class DomainEndpointTests(ApiWebApplicationFactory factory)
|
|||||||
var (token, workspaceId) = await GetAuthAndWorkspaceAsync("delete-domain@example.com");
|
var (token, workspaceId) = await GetAuthAndWorkspaceAsync("delete-domain@example.com");
|
||||||
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||||
|
|
||||||
var createResponse = await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/domains", new { Hostname = "to-delete.com" });
|
var createResponse =
|
||||||
|
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/domains", new { Hostname = "to-delete.com" });
|
||||||
var created = await createResponse.Content.ReadFromJsonAsync<DomainResponse>();
|
var created = await createResponse.Content.ReadFromJsonAsync<DomainResponse>();
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
@@ -168,11 +169,13 @@ public class DomainEndpointTests(ApiWebApplicationFactory factory)
|
|||||||
var (token, workspaceId) = await GetAuthAndWorkspaceAsync("verify-domain@example.com");
|
var (token, workspaceId) = await GetAuthAndWorkspaceAsync("verify-domain@example.com");
|
||||||
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||||
|
|
||||||
var createResponse = await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/domains", new { Hostname = "unverified.com" });
|
var createResponse =
|
||||||
|
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/domains", new { Hostname = "unverified.com" });
|
||||||
var created = await createResponse.Content.ReadFromJsonAsync<DomainResponse>();
|
var created = await createResponse.Content.ReadFromJsonAsync<DomainResponse>();
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var response = await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/domains/{created!.Id}/verify", new { });
|
var response =
|
||||||
|
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/domains/{created!.Id}/verify", new { });
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||||
@@ -189,11 +192,13 @@ public class DomainEndpointTests(ApiWebApplicationFactory factory)
|
|||||||
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||||
|
|
||||||
// The verification mock accepts domains starting with "verified-"
|
// The verification mock accepts domains starting with "verified-"
|
||||||
var createResponse = await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/domains", new { Hostname = "verified-test.com" });
|
var createResponse = await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/domains",
|
||||||
|
new { Hostname = "verified-test.com" });
|
||||||
var created = await createResponse.Content.ReadFromJsonAsync<DomainResponse>();
|
var created = await createResponse.Content.ReadFromJsonAsync<DomainResponse>();
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var response = await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/domains/{created!.Id}/verify", new { });
|
var response =
|
||||||
|
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/domains/{created!.Id}/verify", new { });
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||||
@@ -210,7 +215,8 @@ public class DomainEndpointTests(ApiWebApplicationFactory factory)
|
|||||||
var (token2, _) = await GetAuthAndWorkspaceAsync("domain-user2@example.com");
|
var (token2, _) = await GetAuthAndWorkspaceAsync("domain-user2@example.com");
|
||||||
|
|
||||||
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token1);
|
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token1);
|
||||||
var createResponse = await _client.PostAsJsonAsync($"/workspaces/{workspaceId1}/domains", new { Hostname = "user1-domain.com" });
|
var createResponse = await _client.PostAsJsonAsync($"/workspaces/{workspaceId1}/domains",
|
||||||
|
new { Hostname = "user1-domain.com" });
|
||||||
var created = await createResponse.Content.ReadFromJsonAsync<DomainResponse>();
|
var created = await createResponse.Content.ReadFromJsonAsync<DomainResponse>();
|
||||||
|
|
||||||
// Act - Try to access as user2
|
// Act - Try to access as user2
|
||||||
@@ -1,35 +1,30 @@
|
|||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Net.Http.Headers;
|
using System.Net.Http.Headers;
|
||||||
using System.Net.Http.Json;
|
using System.Net.Http.Json;
|
||||||
using api.Features.Auth.Common;
|
|
||||||
using api.Features.Links.Common;
|
|
||||||
using api.Features.Workspaces.Common;
|
|
||||||
using FluentAssertions;
|
using FluentAssertions;
|
||||||
using Microsoft.AspNetCore.Mvc.Testing;
|
using Microsoft.AspNetCore.Mvc.Testing;
|
||||||
|
using TrackQrApi.Features.Auth.Common;
|
||||||
|
using TrackQrApi.Features.Links.Common;
|
||||||
|
using TrackQrApi.Features.Workspaces.Common;
|
||||||
|
|
||||||
namespace Api.Tests;
|
namespace TrackQrApi.Tests;
|
||||||
|
|
||||||
public class EventTrackingTests : IClassFixture<ApiWebApplicationFactory>
|
public class EventTrackingTests(
|
||||||
|
ApiWebApplicationFactory factory)
|
||||||
|
: IClassFixture<ApiWebApplicationFactory>
|
||||||
{
|
{
|
||||||
private readonly HttpClient _client;
|
private readonly HttpClient _client = factory.CreateClient();
|
||||||
private readonly HttpClient _noRedirectClient;
|
|
||||||
|
|
||||||
public EventTrackingTests(ApiWebApplicationFactory factory)
|
private readonly HttpClient _noRedirectClient = factory.CreateClient(new WebApplicationFactoryClientOptions
|
||||||
{
|
{
|
||||||
_client = factory.CreateClient();
|
AllowAutoRedirect = false
|
||||||
_noRedirectClient = factory.CreateClient(new WebApplicationFactoryClientOptions
|
});
|
||||||
{
|
|
||||||
AllowAutoRedirect = false
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<(string Token, Guid WorkspaceId)> SetupAuthAndWorkspaceAsync(string email)
|
private async Task<(string Token, Guid WorkspaceId)> SetupAuthAndWorkspaceAsync(string email)
|
||||||
{
|
{
|
||||||
var response = await _client.PostAsJsonAsync("/auth/register", new { Email = email, Password = "password123" });
|
var response = await _client.PostAsJsonAsync("/auth/register", new { Email = email, Password = "password123" });
|
||||||
if (response.StatusCode == HttpStatusCode.Conflict)
|
if (response.StatusCode == HttpStatusCode.Conflict)
|
||||||
{
|
|
||||||
response = await _client.PostAsJsonAsync("/auth/login", new { Email = email, Password = "password123" });
|
response = await _client.PostAsJsonAsync("/auth/login", new { Email = email, Password = "password123" });
|
||||||
}
|
|
||||||
var authResult = await response.Content.ReadFromJsonAsync<AuthResponse>();
|
var authResult = await response.Content.ReadFromJsonAsync<AuthResponse>();
|
||||||
var token = authResult!.Token;
|
var token = authResult!.Token;
|
||||||
|
|
||||||
@@ -82,10 +77,7 @@ public class EventTrackingTests : IClassFixture<ApiWebApplicationFactory>
|
|||||||
|
|
||||||
// Act - Click the same link multiple times rapidly
|
// Act - Click the same link multiple times rapidly
|
||||||
var responses = new List<HttpResponseMessage>();
|
var responses = new List<HttpResponseMessage>();
|
||||||
for (int i = 0; i < 5; i++)
|
for (var i = 0; i < 5; i++) responses.Add(await _noRedirectClient.GetAsync($"/{link.Slug}"));
|
||||||
{
|
|
||||||
responses.Add(await _noRedirectClient.GetAsync($"/{link.Slug}"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Assert - All should redirect successfully (deduplication happens silently)
|
// Assert - All should redirect successfully (deduplication happens silently)
|
||||||
responses.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.Redirect);
|
responses.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.Redirect);
|
||||||
@@ -114,7 +106,8 @@ public class EventTrackingTests : IClassFixture<ApiWebApplicationFactory>
|
|||||||
var link = await CreateLinkAsync(workspaceId, "https://example.com", "event-ua-link");
|
var link = await CreateLinkAsync(workspaceId, "https://example.com", "event-ua-link");
|
||||||
|
|
||||||
// Set a custom user agent
|
// Set a custom user agent
|
||||||
_noRedirectClient.DefaultRequestHeaders.UserAgent.ParseAdd("Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X)");
|
_noRedirectClient.DefaultRequestHeaders.UserAgent.ParseAdd(
|
||||||
|
"Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X)");
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var response = await _noRedirectClient.GetAsync($"/{link.Slug}");
|
var response = await _noRedirectClient.GetAsync($"/{link.Slug}");
|
||||||
@@ -1,15 +1,16 @@
|
|||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Net.Http.Headers;
|
using System.Net.Http.Headers;
|
||||||
using System.Net.Http.Json;
|
using System.Net.Http.Json;
|
||||||
using api.Features.Auth.Common;
|
|
||||||
using api.Features.Links.Common;
|
|
||||||
using api.Features.Projects.Common;
|
|
||||||
using api.Features.Workspaces.Common;
|
|
||||||
using FluentAssertions;
|
using FluentAssertions;
|
||||||
|
using TrackQrApi.Features.Auth.Common;
|
||||||
|
using TrackQrApi.Features.Links.Common;
|
||||||
|
using TrackQrApi.Features.Projects.Common;
|
||||||
|
using TrackQrApi.Features.Workspaces.Common;
|
||||||
|
|
||||||
namespace Api.Tests;
|
namespace TrackQrApi.Tests;
|
||||||
|
|
||||||
public class LinkEndpointTests(ApiWebApplicationFactory factory)
|
public class LinkEndpointTests(
|
||||||
|
ApiWebApplicationFactory factory)
|
||||||
: IClassFixture<ApiWebApplicationFactory>
|
: IClassFixture<ApiWebApplicationFactory>
|
||||||
{
|
{
|
||||||
private readonly HttpClient _client = factory.CreateClient();
|
private readonly HttpClient _client = factory.CreateClient();
|
||||||
@@ -18,9 +19,7 @@ public class LinkEndpointTests(ApiWebApplicationFactory factory)
|
|||||||
{
|
{
|
||||||
var response = await _client.PostAsJsonAsync("/auth/register", new { Email = email, Password = "password123" });
|
var response = await _client.PostAsJsonAsync("/auth/register", new { Email = email, Password = "password123" });
|
||||||
if (response.StatusCode == HttpStatusCode.Conflict)
|
if (response.StatusCode == HttpStatusCode.Conflict)
|
||||||
{
|
|
||||||
response = await _client.PostAsJsonAsync("/auth/login", new { Email = email, Password = "password123" });
|
response = await _client.PostAsJsonAsync("/auth/login", new { Email = email, Password = "password123" });
|
||||||
}
|
|
||||||
var authResult = await response.Content.ReadFromJsonAsync<AuthResponse>();
|
var authResult = await response.Content.ReadFromJsonAsync<AuthResponse>();
|
||||||
var token = authResult!.Token;
|
var token = authResult!.Token;
|
||||||
|
|
||||||
@@ -127,7 +126,8 @@ public class LinkEndpointTests(ApiWebApplicationFactory factory)
|
|||||||
var (token, workspaceId) = await SetupAuthAndWorkspaceAsync("create-link-proj@example.com");
|
var (token, workspaceId) = await SetupAuthAndWorkspaceAsync("create-link-proj@example.com");
|
||||||
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||||
|
|
||||||
var projectResponse = await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/projects", new { Name = "Test Project" });
|
var projectResponse =
|
||||||
|
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/projects", new { Name = "Test Project" });
|
||||||
var project = await projectResponse.Content.ReadFromJsonAsync<ProjectResponse>();
|
var project = await projectResponse.Content.ReadFromJsonAsync<ProjectResponse>();
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
@@ -150,8 +150,10 @@ public class LinkEndpointTests(ApiWebApplicationFactory factory)
|
|||||||
var (token, workspaceId) = await SetupAuthAndWorkspaceAsync("list-links@example.com");
|
var (token, workspaceId) = await SetupAuthAndWorkspaceAsync("list-links@example.com");
|
||||||
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||||
|
|
||||||
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/links", new { DestinationUrl = "https://example1.com" });
|
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/links",
|
||||||
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/links", new { DestinationUrl = "https://example2.com" });
|
new { DestinationUrl = "https://example1.com" });
|
||||||
|
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/links",
|
||||||
|
new { DestinationUrl = "https://example2.com" });
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var response = await _client.GetAsync($"/workspaces/{workspaceId}/links");
|
var response = await _client.GetAsync($"/workspaces/{workspaceId}/links");
|
||||||
@@ -169,11 +171,14 @@ public class LinkEndpointTests(ApiWebApplicationFactory factory)
|
|||||||
var (token, workspaceId) = await SetupAuthAndWorkspaceAsync("list-links-proj@example.com");
|
var (token, workspaceId) = await SetupAuthAndWorkspaceAsync("list-links-proj@example.com");
|
||||||
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||||
|
|
||||||
var projectResponse = await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/projects", new { Name = "Filter Project" });
|
var projectResponse =
|
||||||
|
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/projects", new { Name = "Filter Project" });
|
||||||
var project = await projectResponse.Content.ReadFromJsonAsync<ProjectResponse>();
|
var project = await projectResponse.Content.ReadFromJsonAsync<ProjectResponse>();
|
||||||
|
|
||||||
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/links", new { DestinationUrl = "https://in-project.com", ProjectId = project!.Id });
|
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/links",
|
||||||
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/links", new { DestinationUrl = "https://no-project.com" });
|
new { DestinationUrl = "https://in-project.com", ProjectId = project!.Id });
|
||||||
|
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/links",
|
||||||
|
new { DestinationUrl = "https://no-project.com" });
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var response = await _client.GetAsync($"/workspaces/{workspaceId}/links?projectId={project.Id}");
|
var response = await _client.GetAsync($"/workspaces/{workspaceId}/links?projectId={project.Id}");
|
||||||
@@ -372,7 +377,8 @@ public class LinkEndpointTests(ApiWebApplicationFactory factory)
|
|||||||
|
|
||||||
// Act
|
// Act
|
||||||
var getResponse = await _client.GetAsync($"/workspaces/{workspaceId1}/links/{created!.Id}");
|
var getResponse = await _client.GetAsync($"/workspaces/{workspaceId1}/links/{created!.Id}");
|
||||||
var updateResponse = await _client.PutAsJsonAsync($"/workspaces/{workspaceId1}/links/{created.Id}", new { Title = "Hacked" });
|
var updateResponse =
|
||||||
|
await _client.PutAsJsonAsync($"/workspaces/{workspaceId1}/links/{created.Id}", new { Title = "Hacked" });
|
||||||
var deleteResponse = await _client.DeleteAsync($"/workspaces/{workspaceId1}/links/{created.Id}");
|
var deleteResponse = await _client.DeleteAsync($"/workspaces/{workspaceId1}/links/{created.Id}");
|
||||||
|
|
||||||
// Assert - All should return NotFound (not exposing existence)
|
// Assert - All should return NotFound (not exposing existence)
|
||||||
@@ -1,14 +1,15 @@
|
|||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Net.Http.Headers;
|
using System.Net.Http.Headers;
|
||||||
using System.Net.Http.Json;
|
using System.Net.Http.Json;
|
||||||
using api.Features.Auth.Common;
|
|
||||||
using api.Features.Projects.Common;
|
|
||||||
using api.Features.Workspaces.Common;
|
|
||||||
using FluentAssertions;
|
using FluentAssertions;
|
||||||
|
using TrackQrApi.Features.Auth.Common;
|
||||||
|
using TrackQrApi.Features.Projects.Common;
|
||||||
|
using TrackQrApi.Features.Workspaces.Common;
|
||||||
|
|
||||||
namespace Api.Tests;
|
namespace TrackQrApi.Tests;
|
||||||
|
|
||||||
public class ProjectEndpointTests(ApiWebApplicationFactory factory)
|
public class ProjectEndpointTests(
|
||||||
|
ApiWebApplicationFactory factory)
|
||||||
: IClassFixture<ApiWebApplicationFactory>
|
: IClassFixture<ApiWebApplicationFactory>
|
||||||
{
|
{
|
||||||
private readonly HttpClient _client = factory.CreateClient();
|
private readonly HttpClient _client = factory.CreateClient();
|
||||||
@@ -17,9 +18,7 @@ public class ProjectEndpointTests(ApiWebApplicationFactory factory)
|
|||||||
{
|
{
|
||||||
var response = await _client.PostAsJsonAsync("/auth/register", new { Email = email, Password = "password123" });
|
var response = await _client.PostAsJsonAsync("/auth/register", new { Email = email, Password = "password123" });
|
||||||
if (response.StatusCode == HttpStatusCode.Conflict)
|
if (response.StatusCode == HttpStatusCode.Conflict)
|
||||||
{
|
|
||||||
response = await _client.PostAsJsonAsync("/auth/login", new { Email = email, Password = "password123" });
|
response = await _client.PostAsJsonAsync("/auth/login", new { Email = email, Password = "password123" });
|
||||||
}
|
|
||||||
var authResult = await response.Content.ReadFromJsonAsync<AuthResponse>();
|
var authResult = await response.Content.ReadFromJsonAsync<AuthResponse>();
|
||||||
var token = authResult!.Token;
|
var token = authResult!.Token;
|
||||||
|
|
||||||
@@ -71,7 +70,8 @@ public class ProjectEndpointTests(ApiWebApplicationFactory factory)
|
|||||||
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var response = await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/projects", new { Name = "Test Project" });
|
var response =
|
||||||
|
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/projects", new { Name = "Test Project" });
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
response.StatusCode.Should().Be(HttpStatusCode.Created);
|
response.StatusCode.Should().Be(HttpStatusCode.Created);
|
||||||
@@ -102,7 +102,8 @@ public class ProjectEndpointTests(ApiWebApplicationFactory factory)
|
|||||||
var (token, workspaceId) = await SetupAuthAndWorkspaceAsync("get-proj@example.com");
|
var (token, workspaceId) = await SetupAuthAndWorkspaceAsync("get-proj@example.com");
|
||||||
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||||
|
|
||||||
var createResponse = await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/projects", new { Name = "Get Test" });
|
var createResponse =
|
||||||
|
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/projects", new { Name = "Get Test" });
|
||||||
var created = await createResponse.Content.ReadFromJsonAsync<ProjectResponse>();
|
var created = await createResponse.Content.ReadFromJsonAsync<ProjectResponse>();
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
@@ -136,11 +137,13 @@ public class ProjectEndpointTests(ApiWebApplicationFactory factory)
|
|||||||
var (token, workspaceId) = await SetupAuthAndWorkspaceAsync("update-proj@example.com");
|
var (token, workspaceId) = await SetupAuthAndWorkspaceAsync("update-proj@example.com");
|
||||||
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||||
|
|
||||||
var createResponse = await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/projects", new { Name = "Original" });
|
var createResponse =
|
||||||
|
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/projects", new { Name = "Original" });
|
||||||
var created = await createResponse.Content.ReadFromJsonAsync<ProjectResponse>();
|
var created = await createResponse.Content.ReadFromJsonAsync<ProjectResponse>();
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var response = await _client.PutAsJsonAsync($"/workspaces/{workspaceId}/projects/{created!.Id}", new { Name = "Updated" });
|
var response = await _client.PutAsJsonAsync($"/workspaces/{workspaceId}/projects/{created!.Id}",
|
||||||
|
new { Name = "Updated" });
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||||
@@ -155,7 +158,8 @@ public class ProjectEndpointTests(ApiWebApplicationFactory factory)
|
|||||||
var (token, workspaceId) = await SetupAuthAndWorkspaceAsync("delete-proj@example.com");
|
var (token, workspaceId) = await SetupAuthAndWorkspaceAsync("delete-proj@example.com");
|
||||||
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||||
|
|
||||||
var createResponse = await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/projects", new { Name = "To Delete" });
|
var createResponse =
|
||||||
|
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/projects", new { Name = "To Delete" });
|
||||||
var created = await createResponse.Content.ReadFromJsonAsync<ProjectResponse>();
|
var created = await createResponse.Content.ReadFromJsonAsync<ProjectResponse>();
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
@@ -178,7 +182,8 @@ public class ProjectEndpointTests(ApiWebApplicationFactory factory)
|
|||||||
|
|
||||||
// Create project as user1
|
// Create project as user1
|
||||||
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token1);
|
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token1);
|
||||||
var createResponse = await _client.PostAsJsonAsync($"/workspaces/{workspaceId1}/projects", new { Name = "User1 Project" });
|
var createResponse =
|
||||||
|
await _client.PostAsJsonAsync($"/workspaces/{workspaceId1}/projects", new { Name = "User1 Project" });
|
||||||
var created = await createResponse.Content.ReadFromJsonAsync<ProjectResponse>();
|
var created = await createResponse.Content.ReadFromJsonAsync<ProjectResponse>();
|
||||||
|
|
||||||
// Try to access as user2
|
// Try to access as user2
|
||||||
@@ -186,7 +191,8 @@ public class ProjectEndpointTests(ApiWebApplicationFactory factory)
|
|||||||
|
|
||||||
// Act
|
// Act
|
||||||
var getResponse = await _client.GetAsync($"/workspaces/{workspaceId1}/projects/{created!.Id}");
|
var getResponse = await _client.GetAsync($"/workspaces/{workspaceId1}/projects/{created!.Id}");
|
||||||
var updateResponse = await _client.PutAsJsonAsync($"/workspaces/{workspaceId1}/projects/{created.Id}", new { Name = "Hacked" });
|
var updateResponse = await _client.PutAsJsonAsync($"/workspaces/{workspaceId1}/projects/{created.Id}",
|
||||||
|
new { Name = "Hacked" });
|
||||||
var deleteResponse = await _client.DeleteAsync($"/workspaces/{workspaceId1}/projects/{created.Id}");
|
var deleteResponse = await _client.DeleteAsync($"/workspaces/{workspaceId1}/projects/{created.Id}");
|
||||||
|
|
||||||
// Assert - All should return NotFound (not exposing existence)
|
// Assert - All should return NotFound (not exposing existence)
|
||||||
@@ -1,15 +1,16 @@
|
|||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Net.Http.Headers;
|
using System.Net.Http.Headers;
|
||||||
using System.Net.Http.Json;
|
using System.Net.Http.Json;
|
||||||
using api.Features.Auth.Common;
|
|
||||||
using api.Features.Links.Common;
|
|
||||||
using api.Features.QRCodes.Common;
|
|
||||||
using api.Features.Workspaces.Common;
|
|
||||||
using FluentAssertions;
|
using FluentAssertions;
|
||||||
|
using TrackQrApi.Features.Auth.Common;
|
||||||
|
using TrackQrApi.Features.Links.Common;
|
||||||
|
using TrackQrApi.Features.QRCodes.Common;
|
||||||
|
using TrackQrApi.Features.Workspaces.Common;
|
||||||
|
|
||||||
namespace Api.Tests;
|
namespace TrackQrApi.Tests;
|
||||||
|
|
||||||
public class QRCodeEndpointTests(ApiWebApplicationFactory factory)
|
public class QrCodeEndpointTests(
|
||||||
|
ApiWebApplicationFactory factory)
|
||||||
: IClassFixture<ApiWebApplicationFactory>
|
: IClassFixture<ApiWebApplicationFactory>
|
||||||
{
|
{
|
||||||
private readonly HttpClient _client = factory.CreateClient();
|
private readonly HttpClient _client = factory.CreateClient();
|
||||||
@@ -18,9 +19,7 @@ public class QRCodeEndpointTests(ApiWebApplicationFactory factory)
|
|||||||
{
|
{
|
||||||
var response = await _client.PostAsJsonAsync("/auth/register", new { Email = email, Password = "password123" });
|
var response = await _client.PostAsJsonAsync("/auth/register", new { Email = email, Password = "password123" });
|
||||||
if (response.StatusCode == HttpStatusCode.Conflict)
|
if (response.StatusCode == HttpStatusCode.Conflict)
|
||||||
{
|
|
||||||
response = await _client.PostAsJsonAsync("/auth/login", new { Email = email, Password = "password123" });
|
response = await _client.PostAsJsonAsync("/auth/login", new { Email = email, Password = "password123" });
|
||||||
}
|
|
||||||
var authResult = await response.Content.ReadFromJsonAsync<AuthResponse>();
|
var authResult = await response.Content.ReadFromJsonAsync<AuthResponse>();
|
||||||
var token = authResult!.Token;
|
var token = authResult!.Token;
|
||||||
|
|
||||||
@@ -139,7 +138,8 @@ public class QRCodeEndpointTests(ApiWebApplicationFactory factory)
|
|||||||
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||||
|
|
||||||
var link = await CreateLinkAsync(workspaceId, "qr-get-link");
|
var link = await CreateLinkAsync(workspaceId, "qr-get-link");
|
||||||
var createResponse = await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/qrcodes", new { ShortLinkId = link.Id });
|
var createResponse =
|
||||||
|
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/qrcodes", new { ShortLinkId = link.Id });
|
||||||
var created = await createResponse.Content.ReadFromJsonAsync<QRCodeResponse>();
|
var created = await createResponse.Content.ReadFromJsonAsync<QRCodeResponse>();
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
@@ -173,7 +173,8 @@ public class QRCodeEndpointTests(ApiWebApplicationFactory factory)
|
|||||||
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||||
|
|
||||||
var link = await CreateLinkAsync(workspaceId, "qr-update-link");
|
var link = await CreateLinkAsync(workspaceId, "qr-update-link");
|
||||||
var createResponse = await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/qrcodes", new { ShortLinkId = link.Id });
|
var createResponse =
|
||||||
|
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/qrcodes", new { ShortLinkId = link.Id });
|
||||||
var created = await createResponse.Content.ReadFromJsonAsync<QRCodeResponse>();
|
var created = await createResponse.Content.ReadFromJsonAsync<QRCodeResponse>();
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
@@ -201,7 +202,8 @@ public class QRCodeEndpointTests(ApiWebApplicationFactory factory)
|
|||||||
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||||
|
|
||||||
var link = await CreateLinkAsync(workspaceId, "qr-delete-link");
|
var link = await CreateLinkAsync(workspaceId, "qr-delete-link");
|
||||||
var createResponse = await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/qrcodes", new { ShortLinkId = link.Id });
|
var createResponse =
|
||||||
|
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/qrcodes", new { ShortLinkId = link.Id });
|
||||||
var created = await createResponse.Content.ReadFromJsonAsync<QRCodeResponse>();
|
var created = await createResponse.Content.ReadFromJsonAsync<QRCodeResponse>();
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
@@ -223,7 +225,8 @@ public class QRCodeEndpointTests(ApiWebApplicationFactory factory)
|
|||||||
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||||
|
|
||||||
var link = await CreateLinkAsync(workspaceId, "qr-preview-link");
|
var link = await CreateLinkAsync(workspaceId, "qr-preview-link");
|
||||||
var createResponse = await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/qrcodes", new { ShortLinkId = link.Id });
|
var createResponse =
|
||||||
|
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/qrcodes", new { ShortLinkId = link.Id });
|
||||||
var created = await createResponse.Content.ReadFromJsonAsync<QRCodeResponse>();
|
var created = await createResponse.Content.ReadFromJsonAsync<QRCodeResponse>();
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
@@ -244,7 +247,8 @@ public class QRCodeEndpointTests(ApiWebApplicationFactory factory)
|
|||||||
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||||
|
|
||||||
var link = await CreateLinkAsync(workspaceId, "qr-export-png-link");
|
var link = await CreateLinkAsync(workspaceId, "qr-export-png-link");
|
||||||
var createResponse = await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/qrcodes", new { ShortLinkId = link.Id });
|
var createResponse =
|
||||||
|
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/qrcodes", new { ShortLinkId = link.Id });
|
||||||
var created = await createResponse.Content.ReadFromJsonAsync<QRCodeResponse>();
|
var created = await createResponse.Content.ReadFromJsonAsync<QRCodeResponse>();
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
@@ -263,7 +267,8 @@ public class QRCodeEndpointTests(ApiWebApplicationFactory factory)
|
|||||||
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||||
|
|
||||||
var link = await CreateLinkAsync(workspaceId, "qr-export-svg-link");
|
var link = await CreateLinkAsync(workspaceId, "qr-export-svg-link");
|
||||||
var createResponse = await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/qrcodes", new { ShortLinkId = link.Id });
|
var createResponse =
|
||||||
|
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/qrcodes", new { ShortLinkId = link.Id });
|
||||||
var created = await createResponse.Content.ReadFromJsonAsync<QRCodeResponse>();
|
var created = await createResponse.Content.ReadFromJsonAsync<QRCodeResponse>();
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
@@ -280,7 +285,8 @@ public class QRCodeEndpointTests(ApiWebApplicationFactory factory)
|
|||||||
// Arrange - Create two users
|
// Arrange - Create two users
|
||||||
var (token1, workspaceId1) = await SetupAuthAndWorkspaceAsync("qr-user1@example.com");
|
var (token1, workspaceId1) = await SetupAuthAndWorkspaceAsync("qr-user1@example.com");
|
||||||
var link = await CreateLinkAsync(workspaceId1, "qr-user1-link");
|
var link = await CreateLinkAsync(workspaceId1, "qr-user1-link");
|
||||||
var createResponse = await _client.PostAsJsonAsync($"/workspaces/{workspaceId1}/qrcodes", new { ShortLinkId = link.Id });
|
var createResponse =
|
||||||
|
await _client.PostAsJsonAsync($"/workspaces/{workspaceId1}/qrcodes", new { ShortLinkId = link.Id });
|
||||||
var created = await createResponse.Content.ReadFromJsonAsync<QRCodeResponse>();
|
var created = await createResponse.Content.ReadFromJsonAsync<QRCodeResponse>();
|
||||||
|
|
||||||
var (token2, _) = await SetupAuthAndWorkspaceAsync("qr-user2@example.com");
|
var (token2, _) = await SetupAuthAndWorkspaceAsync("qr-user2@example.com");
|
||||||
@@ -1,35 +1,32 @@
|
|||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Net.Http.Headers;
|
using System.Net.Http.Headers;
|
||||||
using System.Net.Http.Json;
|
using System.Net.Http.Json;
|
||||||
using api.Features.Auth.Common;
|
|
||||||
using api.Features.Links.Common;
|
|
||||||
using api.Features.Workspaces.Common;
|
|
||||||
using FluentAssertions;
|
using FluentAssertions;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Testing;
|
||||||
|
using TrackQrApi.Features.Auth.Common;
|
||||||
|
using TrackQrApi.Features.Links.Common;
|
||||||
|
using TrackQrApi.Features.Workspaces.Common;
|
||||||
|
|
||||||
namespace Api.Tests;
|
namespace TrackQrApi.Tests;
|
||||||
|
|
||||||
public class RedirectEndpointTests : IClassFixture<ApiWebApplicationFactory>
|
public class RedirectEndpointTests(
|
||||||
|
ApiWebApplicationFactory factory)
|
||||||
|
: IClassFixture<ApiWebApplicationFactory>
|
||||||
{
|
{
|
||||||
private readonly HttpClient _client;
|
private readonly HttpClient _client = factory.CreateClient();
|
||||||
private readonly HttpClient _noRedirectClient;
|
|
||||||
|
|
||||||
public RedirectEndpointTests(ApiWebApplicationFactory factory)
|
private readonly HttpClient _noRedirectClient = factory.CreateClient(new WebApplicationFactoryClientOptions
|
||||||
{
|
{
|
||||||
_client = factory.CreateClient();
|
AllowAutoRedirect = false
|
||||||
// Create a client that doesn't follow redirects
|
});
|
||||||
_noRedirectClient = factory.CreateClient(new Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactoryClientOptions
|
|
||||||
{
|
// Create a client that doesn't follow redirects
|
||||||
AllowAutoRedirect = false
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<(string Token, Guid WorkspaceId)> SetupAuthAndWorkspaceAsync(string email)
|
private async Task<(string Token, Guid WorkspaceId)> SetupAuthAndWorkspaceAsync(string email)
|
||||||
{
|
{
|
||||||
var response = await _client.PostAsJsonAsync("/auth/register", new { Email = email, Password = "password123" });
|
var response = await _client.PostAsJsonAsync("/auth/register", new { Email = email, Password = "password123" });
|
||||||
if (response.StatusCode == HttpStatusCode.Conflict)
|
if (response.StatusCode == HttpStatusCode.Conflict)
|
||||||
{
|
|
||||||
response = await _client.PostAsJsonAsync("/auth/login", new { Email = email, Password = "password123" });
|
response = await _client.PostAsJsonAsync("/auth/login", new { Email = email, Password = "password123" });
|
||||||
}
|
|
||||||
var authResult = await response.Content.ReadFromJsonAsync<AuthResponse>();
|
var authResult = await response.Content.ReadFromJsonAsync<AuthResponse>();
|
||||||
var token = authResult!.Token;
|
var token = authResult!.Token;
|
||||||
|
|
||||||
@@ -42,7 +39,8 @@ public class RedirectEndpointTests : IClassFixture<ApiWebApplicationFactory>
|
|||||||
return (token, workspaceId);
|
return (token, workspaceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<LinkResponse> CreateLinkAsync(Guid workspaceId, string destinationUrl, string? slug = null, string? password = null)
|
private async Task<LinkResponse> CreateLinkAsync(Guid workspaceId, string destinationUrl, string? slug = null,
|
||||||
|
string? password = null)
|
||||||
{
|
{
|
||||||
var createResponse = await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/links", new
|
var createResponse = await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/links", new
|
||||||
{
|
{
|
||||||
@@ -129,7 +127,7 @@ public class RedirectEndpointTests : IClassFixture<ApiWebApplicationFactory>
|
|||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var (_, workspaceId) = await SetupAuthAndWorkspaceAsync("redirect-password@example.com");
|
var (_, workspaceId) = await SetupAuthAndWorkspaceAsync("redirect-password@example.com");
|
||||||
var link = await CreateLinkAsync(workspaceId, "https://example.com", "password-link", password: "secret123");
|
var link = await CreateLinkAsync(workspaceId, "https://example.com", "password-link", "secret123");
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var response = await _client.GetAsync($"/{link.Slug}");
|
var response = await _client.GetAsync($"/{link.Slug}");
|
||||||
@@ -144,7 +142,7 @@ public class RedirectEndpointTests : IClassFixture<ApiWebApplicationFactory>
|
|||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var (_, workspaceId) = await SetupAuthAndWorkspaceAsync("redirect-pass-ok@example.com");
|
var (_, workspaceId) = await SetupAuthAndWorkspaceAsync("redirect-pass-ok@example.com");
|
||||||
var link = await CreateLinkAsync(workspaceId, "https://example.com", "password-ok-link", password: "secret123");
|
var link = await CreateLinkAsync(workspaceId, "https://example.com", "password-ok-link", "secret123");
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var response = await _noRedirectClient.PostAsJsonAsync($"/{link.Slug}", new { Password = "secret123" });
|
var response = await _noRedirectClient.PostAsJsonAsync($"/{link.Slug}", new { Password = "secret123" });
|
||||||
@@ -159,7 +157,7 @@ public class RedirectEndpointTests : IClassFixture<ApiWebApplicationFactory>
|
|||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var (_, workspaceId) = await SetupAuthAndWorkspaceAsync("redirect-pass-wrong@example.com");
|
var (_, workspaceId) = await SetupAuthAndWorkspaceAsync("redirect-pass-wrong@example.com");
|
||||||
var link = await CreateLinkAsync(workspaceId, "https://example.com", "password-wrong-link", password: "secret123");
|
var link = await CreateLinkAsync(workspaceId, "https://example.com", "password-wrong-link", "secret123");
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var response = await _client.PostAsJsonAsync($"/{link.Slug}", new { Password = "wrongpassword" });
|
var response = await _client.PostAsJsonAsync($"/{link.Slug}", new { Password = "wrongpassword" });
|
||||||
@@ -173,7 +171,7 @@ public class RedirectEndpointTests : IClassFixture<ApiWebApplicationFactory>
|
|||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var (_, workspaceId) = await SetupAuthAndWorkspaceAsync("redirect-pass-empty@example.com");
|
var (_, workspaceId) = await SetupAuthAndWorkspaceAsync("redirect-pass-empty@example.com");
|
||||||
var link = await CreateLinkAsync(workspaceId, "https://example.com", "password-empty-link", password: "secret123");
|
var link = await CreateLinkAsync(workspaceId, "https://example.com", "password-empty-link", "secret123");
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var response = await _client.PostAsJsonAsync($"/{link.Slug}", new { Password = "" });
|
var response = await _client.PostAsJsonAsync($"/{link.Slug}", new { Password = "" });
|
||||||
28
src/TrackApi/TrackQrApi.Tests/TrackQrApi.Tests.csproj
Normal file
28
src/TrackApi/TrackQrApi.Tests/TrackQrApi.Tests.csproj
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="coverlet.collector" Version="6.0.4"/>
|
||||||
|
<PackageReference Include="FluentAssertions" Version="8.8.0"/>
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.2"/>
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1"/>
|
||||||
|
<PackageReference Include="Testcontainers.PostgreSql" Version="4.10.0"/>
|
||||||
|
<PackageReference Include="xunit" Version="2.9.3"/>
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Using Include="Xunit"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\TrackQrApi\TrackQrApi.csproj"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -1,24 +1,25 @@
|
|||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Net.Http.Headers;
|
using System.Net.Http.Headers;
|
||||||
using System.Net.Http.Json;
|
using System.Net.Http.Json;
|
||||||
using api.Features.Auth.Common;
|
|
||||||
using api.Features.Workspaces.Common;
|
|
||||||
using FluentAssertions;
|
using FluentAssertions;
|
||||||
|
using TrackQrApi.Features.Auth.Common;
|
||||||
|
using TrackQrApi.Features.Workspaces.Common;
|
||||||
|
|
||||||
namespace Api.Tests;
|
namespace TrackQrApi.Tests;
|
||||||
|
|
||||||
public class WorkspaceEndpointTests(ApiWebApplicationFactory factory)
|
public class WorkspaceEndpointTests(
|
||||||
|
ApiWebApplicationFactory factory)
|
||||||
: IClassFixture<ApiWebApplicationFactory>
|
: IClassFixture<ApiWebApplicationFactory>
|
||||||
{
|
{
|
||||||
private readonly HttpClient _client = factory.CreateClient();
|
private readonly HttpClient _client = factory.CreateClient();
|
||||||
|
|
||||||
private async Task<(string Token, Guid WorkspaceId)> GetAuthAndWorkspaceAsync(string email, bool upgradeToPro = false)
|
private async Task<(string Token, Guid WorkspaceId)> GetAuthAndWorkspaceAsync(string email,
|
||||||
|
bool upgradeToPro = false)
|
||||||
{
|
{
|
||||||
var response = await _client.PostAsJsonAsync("/auth/register", new { Email = email, Password = "password123" });
|
var response = await _client.PostAsJsonAsync("/auth/register", new { Email = email, Password = "password123" });
|
||||||
if (response.StatusCode == HttpStatusCode.Conflict)
|
if (response.StatusCode == HttpStatusCode.Conflict)
|
||||||
{
|
|
||||||
response = await _client.PostAsJsonAsync("/auth/login", new { Email = email, Password = "password123" });
|
response = await _client.PostAsJsonAsync("/auth/login", new { Email = email, Password = "password123" });
|
||||||
}
|
|
||||||
var result = await response.Content.ReadFromJsonAsync<AuthResponse>();
|
var result = await response.Content.ReadFromJsonAsync<AuthResponse>();
|
||||||
var token = result!.Token;
|
var token = result!.Token;
|
||||||
|
|
||||||
@@ -27,10 +28,7 @@ public class WorkspaceEndpointTests(ApiWebApplicationFactory factory)
|
|||||||
var workspaces = await workspacesResponse.Content.ReadFromJsonAsync<WorkspaceListResponse>();
|
var workspaces = await workspacesResponse.Content.ReadFromJsonAsync<WorkspaceListResponse>();
|
||||||
var workspaceId = workspaces!.Workspaces.First().Id;
|
var workspaceId = workspaces!.Workspaces.First().Id;
|
||||||
|
|
||||||
if (upgradeToPro)
|
if (upgradeToPro) await factory.UpgradeWorkspaceToPro(workspaceId);
|
||||||
{
|
|
||||||
await factory.UpgradeWorkspaceToPro(workspaceId);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (token, workspaceId);
|
return (token, workspaceId);
|
||||||
}
|
}
|
||||||
@@ -72,7 +70,7 @@ public class WorkspaceEndpointTests(ApiWebApplicationFactory factory)
|
|||||||
public async Task CreateWorkspace_WithValidData_ReturnsCreated()
|
public async Task CreateWorkspace_WithValidData_ReturnsCreated()
|
||||||
{
|
{
|
||||||
// Arrange - upgrade to Pro to allow creating additional workspaces
|
// Arrange - upgrade to Pro to allow creating additional workspaces
|
||||||
var (token, _) = await GetAuthAndWorkspaceAsync("create-ws@example.com", upgradeToPro: true);
|
var (token, _) = await GetAuthAndWorkspaceAsync("create-ws@example.com", true);
|
||||||
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
@@ -152,7 +150,7 @@ public class WorkspaceEndpointTests(ApiWebApplicationFactory factory)
|
|||||||
public async Task DeleteWorkspace_WithValidId_ReturnsSuccess()
|
public async Task DeleteWorkspace_WithValidId_ReturnsSuccess()
|
||||||
{
|
{
|
||||||
// Arrange - upgrade to Pro to allow creating additional workspaces
|
// Arrange - upgrade to Pro to allow creating additional workspaces
|
||||||
var (token, _) = await GetAuthAndWorkspaceAsync("delete-ws@example.com", upgradeToPro: true);
|
var (token, _) = await GetAuthAndWorkspaceAsync("delete-ws@example.com", true);
|
||||||
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||||
|
|
||||||
var createResponse = await _client.PostAsJsonAsync("/workspaces", new { Name = "To Delete" });
|
var createResponse = await _client.PostAsJsonAsync("/workspaces", new { Name = "To Delete" });
|
||||||
4
src/TrackApi/TrackQrApi.slnx
Normal file
4
src/TrackApi/TrackQrApi.slnx
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<Solution>
|
||||||
|
<Project Path="TrackQrApi.Tests\TrackQrApi.Tests.csproj"/>
|
||||||
|
<Project Path="TrackQrApi\TrackQrApi.csproj"/>
|
||||||
|
</Solution>
|
||||||
8
src/TrackApi/TrackQrApi.slnx.DotSettings.user
Normal file
8
src/TrackApi/TrackQrApi.slnx.DotSettings.user
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
|
||||||
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ACorsPolicyBuilder_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003F92505ca94c450e3c7516c94691f62dfebf5577d7f7cca72ab4e7742727633_003FCorsPolicyBuilder_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AExceptionDispatchInfo_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003Fbf9021a960b74107a7e141aa06bc9d8a0a53c929178c2fb95b1597be8af8dc_003FExceptionDispatchInfo_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AMonitor_002ECoreCLR_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003Fbc69852c736be69a33ab75e0444246ffeb2f8cd671d12b36b764ba5fa18f61ba_003FMonitor_002ECoreCLR_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003APostgreSqlBuilder_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FSourcesCache_003Fcdd0beaf7beaf8366c0862f34fe40da30911084d957625ab31577851ee8cae7_003FPostgreSqlBuilder_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
|
<s:String x:Key="/Default/Environment/UnitTesting/UnitTestSessionStore/Sessions/=f24d9dca_002Dcc3a_002D42e4_002D8e9d_002D00aa5709be91/@EntryIndexedValue"><SessionState ContinuousTestingMode="0" IsActive="True" Name="All tests from &lt;api.Tests&gt;" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session">
|
||||||
|
<Project Location="/home/jbourdon/repos/trakqr/src/api.Tests" Presentation="&lt;api.Tests&gt;" />
|
||||||
|
</SessionState></s:String></wpf:ResourceDictionary>
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
using api.Models;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TrackQrApi.Models;
|
||||||
|
|
||||||
namespace api.Data;
|
namespace TrackQrApi.Data;
|
||||||
|
|
||||||
public class AppDbContext(DbContextOptions<AppDbContext> options)
|
public class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||||
: DbContext(options)
|
: DbContext(options)
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
namespace api.Features.Analytics.Common;
|
namespace TrackQrApi.Features.Analytics.Common;
|
||||||
|
|
||||||
public record AnalyticsSummary(
|
public record AnalyticsSummary(
|
||||||
int TotalClicks,
|
int TotalClicks,
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using api.Data;
|
|
||||||
using api.Features.Analytics.Common;
|
|
||||||
using api.Features.Auth.Common;
|
|
||||||
using api.Models;
|
|
||||||
using FastEndpoints;
|
using FastEndpoints;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TrackQrApi.Data;
|
||||||
|
using TrackQrApi.Features.Analytics.Common;
|
||||||
|
using TrackQrApi.Features.Auth.Common;
|
||||||
|
using TrackQrApi.Models;
|
||||||
|
|
||||||
namespace api.Features.Analytics.Endpoints;
|
namespace TrackQrApi.Features.Analytics.Endpoints;
|
||||||
|
|
||||||
public class LinkAnalyticsRequest
|
public class LinkAnalyticsRequest
|
||||||
{
|
{
|
||||||
@@ -59,26 +59,20 @@ public class LinkAnalyticsEndpoint(AppDbContext db)
|
|||||||
var eventsQuery = db.Events
|
var eventsQuery = db.Events
|
||||||
.Where(e => e.ShortLinkId == req.Id);
|
.Where(e => e.ShortLinkId == req.Id);
|
||||||
|
|
||||||
if (startDate.HasValue)
|
if (startDate.HasValue) eventsQuery = eventsQuery.Where(e => e.Timestamp >= startDate.Value);
|
||||||
{
|
|
||||||
eventsQuery = eventsQuery.Where(e => e.Timestamp >= startDate.Value);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (endDate.HasValue)
|
if (endDate.HasValue) eventsQuery = eventsQuery.Where(e => e.Timestamp < endDate.Value);
|
||||||
{
|
|
||||||
eventsQuery = eventsQuery.Where(e => e.Timestamp < endDate.Value);
|
|
||||||
}
|
|
||||||
|
|
||||||
var events = await eventsQuery.ToListAsync(ct);
|
var events = await eventsQuery.ToListAsync(ct);
|
||||||
var totalEvents = events.Count;
|
var totalEvents = events.Count;
|
||||||
|
|
||||||
// Build summary
|
// Build summary
|
||||||
var summary = new AnalyticsSummary(
|
var summary = new AnalyticsSummary(
|
||||||
TotalClicks: events.Count(e => e.Type == EventType.Click),
|
events.Count(e => e.Type == EventType.Click),
|
||||||
TotalScans: events.Count(e => e.Type == EventType.Scan),
|
events.Count(e => e.Type == EventType.Scan),
|
||||||
UniqueVisitors: events.Select(e => e.IpHash).Distinct().Count(),
|
events.Select(e => e.IpHash).Distinct().Count(),
|
||||||
FirstEvent: events.MinBy(e => e.Timestamp)?.Timestamp,
|
events.MinBy(e => e.Timestamp)?.Timestamp,
|
||||||
LastEvent: events.MaxBy(e => e.Timestamp)?.Timestamp
|
events.MaxBy(e => e.Timestamp)?.Timestamp
|
||||||
);
|
);
|
||||||
|
|
||||||
// Build time series
|
// Build time series
|
||||||
@@ -86,9 +80,9 @@ public class LinkAnalyticsEndpoint(AppDbContext db)
|
|||||||
.GroupBy(e => e.Timestamp.Date)
|
.GroupBy(e => e.Timestamp.Date)
|
||||||
.OrderBy(g => g.Key)
|
.OrderBy(g => g.Key)
|
||||||
.Select(g => new TimeSeriesPoint(
|
.Select(g => new TimeSeriesPoint(
|
||||||
Date: g.Key,
|
g.Key,
|
||||||
Clicks: g.Count(e => e.Type == EventType.Click),
|
g.Count(e => e.Type == EventType.Click),
|
||||||
Scans: g.Count(e => e.Type == EventType.Scan)
|
g.Count(e => e.Type == EventType.Scan)
|
||||||
))
|
))
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
@@ -131,16 +125,16 @@ public class LinkAnalyticsEndpoint(AppDbContext db)
|
|||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
var response = new LinkAnalyticsResponse(
|
var response = new LinkAnalyticsResponse(
|
||||||
LinkId: link.Id,
|
link.Id,
|
||||||
Slug: link.Slug,
|
link.Slug,
|
||||||
Summary: summary,
|
summary,
|
||||||
TimeSeries: timeSeries,
|
timeSeries,
|
||||||
DeviceBreakdown: deviceBreakdown,
|
deviceBreakdown,
|
||||||
ReferrerBreakdown: referrerBreakdown,
|
referrerBreakdown,
|
||||||
CountryBreakdown: countryBreakdown
|
countryBreakdown
|
||||||
);
|
);
|
||||||
|
|
||||||
await HttpContext.Response.SendAsync(response, 200, cancellation: ct);
|
await HttpContext.Response.SendAsync(response, cancellation: ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static DateTime? GetStartDate(string? period)
|
private static DateTime? GetStartDate(string? period)
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using api.Data;
|
|
||||||
using api.Features.Analytics.Common;
|
|
||||||
using api.Features.Auth.Common;
|
|
||||||
using api.Models;
|
|
||||||
using FastEndpoints;
|
using FastEndpoints;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TrackQrApi.Data;
|
||||||
|
using TrackQrApi.Features.Analytics.Common;
|
||||||
|
using TrackQrApi.Features.Auth.Common;
|
||||||
|
using TrackQrApi.Models;
|
||||||
|
|
||||||
namespace api.Features.Analytics.Endpoints;
|
namespace TrackQrApi.Features.Analytics.Endpoints;
|
||||||
|
|
||||||
public class WorkspaceAnalyticsRequest
|
public class WorkspaceAnalyticsRequest
|
||||||
{
|
{
|
||||||
@@ -56,26 +56,20 @@ public class WorkspaceAnalyticsEndpoint(AppDbContext db)
|
|||||||
var eventsQuery = db.Events
|
var eventsQuery = db.Events
|
||||||
.Where(e => e.WorkspaceId == req.WorkspaceId);
|
.Where(e => e.WorkspaceId == req.WorkspaceId);
|
||||||
|
|
||||||
if (startDate.HasValue)
|
if (startDate.HasValue) eventsQuery = eventsQuery.Where(e => e.Timestamp >= startDate.Value);
|
||||||
{
|
|
||||||
eventsQuery = eventsQuery.Where(e => e.Timestamp >= startDate.Value);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (endDate.HasValue)
|
if (endDate.HasValue) eventsQuery = eventsQuery.Where(e => e.Timestamp < endDate.Value);
|
||||||
{
|
|
||||||
eventsQuery = eventsQuery.Where(e => e.Timestamp < endDate.Value);
|
|
||||||
}
|
|
||||||
|
|
||||||
var events = await eventsQuery.ToListAsync(ct);
|
var events = await eventsQuery.ToListAsync(ct);
|
||||||
var totalEvents = events.Count;
|
var totalEvents = events.Count;
|
||||||
|
|
||||||
// Get summary
|
// Get summary
|
||||||
var summary = new AnalyticsSummary(
|
var summary = new AnalyticsSummary(
|
||||||
TotalClicks: events.Count(e => e.Type == EventType.Click),
|
events.Count(e => e.Type == EventType.Click),
|
||||||
TotalScans: events.Count(e => e.Type == EventType.Scan),
|
events.Count(e => e.Type == EventType.Scan),
|
||||||
UniqueVisitors: events.Select(e => e.IpHash).Distinct().Count(),
|
events.Select(e => e.IpHash).Distinct().Count(),
|
||||||
FirstEvent: events.Count > 0 ? events.Min(e => e.Timestamp) : null,
|
events.Count > 0 ? events.Min(e => e.Timestamp) : null,
|
||||||
LastEvent: events.Count > 0 ? events.Max(e => e.Timestamp) : null
|
events.Count > 0 ? events.Max(e => e.Timestamp) : null
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get time series
|
// Get time series
|
||||||
@@ -83,9 +77,9 @@ public class WorkspaceAnalyticsEndpoint(AppDbContext db)
|
|||||||
.GroupBy(e => e.Timestamp.Date)
|
.GroupBy(e => e.Timestamp.Date)
|
||||||
.OrderBy(g => g.Key)
|
.OrderBy(g => g.Key)
|
||||||
.Select(g => new TimeSeriesPoint(
|
.Select(g => new TimeSeriesPoint(
|
||||||
Date: g.Key,
|
g.Key,
|
||||||
Clicks: g.Count(e => e.Type == EventType.Click),
|
g.Count(e => e.Type == EventType.Click),
|
||||||
Scans: g.Count(e => e.Type == EventType.Scan)
|
g.Count(e => e.Type == EventType.Scan)
|
||||||
))
|
))
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
@@ -146,15 +140,15 @@ public class WorkspaceAnalyticsEndpoint(AppDbContext db)
|
|||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
var response = new WorkspaceAnalyticsResponse(
|
var response = new WorkspaceAnalyticsResponse(
|
||||||
Summary: summary,
|
summary,
|
||||||
TimeSeries: timeSeries,
|
timeSeries,
|
||||||
TopLinks: topLinks,
|
topLinks,
|
||||||
DeviceBreakdown: deviceBreakdown,
|
deviceBreakdown,
|
||||||
ReferrerBreakdown: referrerBreakdown,
|
referrerBreakdown,
|
||||||
CountryBreakdown: countryBreakdown
|
countryBreakdown
|
||||||
);
|
);
|
||||||
|
|
||||||
await HttpContext.Response.SendAsync(response, 200, cancellation: ct);
|
await HttpContext.Response.SendAsync(response, cancellation: ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static DateTime? GetStartDate(string? period)
|
private static DateTime? GetStartDate(string? period)
|
||||||
@@ -1,12 +1,13 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
using api.Data;
|
using System.Text;
|
||||||
using api.Features.Auth.Common;
|
|
||||||
using api.Models;
|
|
||||||
using FastEndpoints;
|
using FastEndpoints;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TrackQrApi.Data;
|
||||||
|
using TrackQrApi.Features.Auth.Common;
|
||||||
|
using TrackQrApi.Models;
|
||||||
|
|
||||||
namespace api.Features.ApiKeys.Endpoints;
|
namespace TrackQrApi.Features.ApiKeys.Endpoints;
|
||||||
|
|
||||||
public class CreateApiKeyRequest
|
public class CreateApiKeyRequest
|
||||||
{
|
{
|
||||||
@@ -32,7 +33,7 @@ public class CreateApiKeyEndpoint(AppDbContext db)
|
|||||||
{
|
{
|
||||||
public override void Configure()
|
public override void Configure()
|
||||||
{
|
{
|
||||||
Post("/workspaces/{WorkspaceId}/api-keys");
|
Post("/workspaces/{WorkspaceId}/TrackQrApi-keys");
|
||||||
}
|
}
|
||||||
|
|
||||||
public override async Task HandleAsync(CreateApiKeyRequest req, CancellationToken ct)
|
public override async Task HandleAsync(CreateApiKeyRequest req, CancellationToken ct)
|
||||||
@@ -53,7 +54,8 @@ public class CreateApiKeyEndpoint(AppDbContext db)
|
|||||||
var existingCount = await db.ApiKeys.CountAsync(k => k.WorkspaceId == req.WorkspaceId && k.IsActive, ct);
|
var existingCount = await db.ApiKeys.CountAsync(k => k.WorkspaceId == req.WorkspaceId && k.IsActive, ct);
|
||||||
if (existingCount >= 10)
|
if (existingCount >= 10)
|
||||||
{
|
{
|
||||||
await HttpContext.Response.SendAsync(new MessageResponse("Maximum 10 API keys per workspace"), 400, cancellation: ct);
|
await HttpContext.Response.SendAsync(new MessageResponse("Maximum 10 API keys per workspace"), 400,
|
||||||
|
cancellation: ct);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,7 +75,7 @@ public class CreateApiKeyEndpoint(AppDbContext db)
|
|||||||
Scopes = req.Scopes,
|
Scopes = req.Scopes,
|
||||||
ExpiresAt = req.ExpiresAt,
|
ExpiresAt = req.ExpiresAt,
|
||||||
CreatedAt = DateTime.UtcNow,
|
CreatedAt = DateTime.UtcNow,
|
||||||
IsActive = true,
|
IsActive = true
|
||||||
};
|
};
|
||||||
|
|
||||||
db.ApiKeys.Add(apiKey);
|
db.ApiKeys.Add(apiKey);
|
||||||
@@ -87,7 +89,7 @@ public class CreateApiKeyEndpoint(AppDbContext db)
|
|||||||
KeyPrefix = keyPrefix,
|
KeyPrefix = keyPrefix,
|
||||||
Scopes = apiKey.Scopes,
|
Scopes = apiKey.Scopes,
|
||||||
ExpiresAt = apiKey.ExpiresAt,
|
ExpiresAt = apiKey.ExpiresAt,
|
||||||
CreatedAt = apiKey.CreatedAt,
|
CreatedAt = apiKey.CreatedAt
|
||||||
};
|
};
|
||||||
|
|
||||||
await HttpContext.Response.SendAsync(response, 201, cancellation: ct);
|
await HttpContext.Response.SendAsync(response, 201, cancellation: ct);
|
||||||
@@ -95,7 +97,7 @@ public class CreateApiKeyEndpoint(AppDbContext db)
|
|||||||
|
|
||||||
private static string ComputeSha256Hash(string input)
|
private static string ComputeSha256Hash(string input)
|
||||||
{
|
{
|
||||||
var bytes = System.Text.Encoding.UTF8.GetBytes(input);
|
var bytes = Encoding.UTF8.GetBytes(input);
|
||||||
var hash = SHA256.HashData(bytes);
|
var hash = SHA256.HashData(bytes);
|
||||||
return Convert.ToHexString(hash).ToLower();
|
return Convert.ToHexString(hash).ToLower();
|
||||||
}
|
}
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using api.Data;
|
|
||||||
using api.Features.Auth.Common;
|
|
||||||
using FastEndpoints;
|
using FastEndpoints;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TrackQrApi.Data;
|
||||||
|
using TrackQrApi.Features.Auth.Common;
|
||||||
|
|
||||||
namespace api.Features.ApiKeys.Endpoints;
|
namespace TrackQrApi.Features.ApiKeys.Endpoints;
|
||||||
|
|
||||||
public class DeleteApiKeyRequest
|
public class DeleteApiKeyRequest
|
||||||
{
|
{
|
||||||
@@ -17,7 +17,7 @@ public class DeleteApiKeyEndpoint(AppDbContext db)
|
|||||||
{
|
{
|
||||||
public override void Configure()
|
public override void Configure()
|
||||||
{
|
{
|
||||||
Delete("/workspaces/{WorkspaceId}/api-keys/{Id}");
|
Delete("/workspaces/{WorkspaceId}/TrackQrApi-keys/{Id}");
|
||||||
}
|
}
|
||||||
|
|
||||||
public override async Task HandleAsync(DeleteApiKeyRequest req, CancellationToken ct)
|
public override async Task HandleAsync(DeleteApiKeyRequest req, CancellationToken ct)
|
||||||
@@ -46,6 +46,6 @@ public class DeleteApiKeyEndpoint(AppDbContext db)
|
|||||||
db.ApiKeys.Remove(apiKey);
|
db.ApiKeys.Remove(apiKey);
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
await HttpContext.Response.SendAsync(new MessageResponse("API key deleted"), 200, cancellation: ct);
|
await HttpContext.Response.SendAsync(new MessageResponse("API key deleted"), cancellation: ct);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using api.Data;
|
|
||||||
using api.Features.Auth.Common;
|
|
||||||
using FastEndpoints;
|
using FastEndpoints;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TrackQrApi.Data;
|
||||||
|
using TrackQrApi.Features.Auth.Common;
|
||||||
|
|
||||||
namespace api.Features.ApiKeys.Endpoints;
|
namespace TrackQrApi.Features.ApiKeys.Endpoints;
|
||||||
|
|
||||||
public class ListApiKeysRequest
|
public class ListApiKeysRequest
|
||||||
{
|
{
|
||||||
@@ -33,7 +33,7 @@ public class ListApiKeysEndpoint(AppDbContext db)
|
|||||||
{
|
{
|
||||||
public override void Configure()
|
public override void Configure()
|
||||||
{
|
{
|
||||||
Get("/workspaces/{WorkspaceId}/api-keys");
|
Get("/workspaces/{WorkspaceId}/TrackQrApi-keys");
|
||||||
}
|
}
|
||||||
|
|
||||||
public override async Task HandleAsync(ListApiKeysRequest req, CancellationToken ct)
|
public override async Task HandleAsync(ListApiKeysRequest req, CancellationToken ct)
|
||||||
@@ -62,11 +62,11 @@ public class ListApiKeysEndpoint(AppDbContext db)
|
|||||||
ExpiresAt = k.ExpiresAt,
|
ExpiresAt = k.ExpiresAt,
|
||||||
LastUsedAt = k.LastUsedAt,
|
LastUsedAt = k.LastUsedAt,
|
||||||
CreatedAt = k.CreatedAt,
|
CreatedAt = k.CreatedAt,
|
||||||
IsActive = k.IsActive,
|
IsActive = k.IsActive
|
||||||
})
|
})
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
|
|
||||||
var response = new ListApiKeysResponse { ApiKeys = apiKeys };
|
var response = new ListApiKeysResponse { ApiKeys = apiKeys };
|
||||||
await HttpContext.Response.SendAsync(response, 200, cancellation: ct);
|
await HttpContext.Response.SendAsync(response, cancellation: ct);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
namespace api.Features.Assets.Common;
|
namespace TrackQrApi.Features.Assets.Common;
|
||||||
|
|
||||||
public record AssetResponse(
|
public record AssetResponse(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using api.Data;
|
|
||||||
using api.Features.Assets.Services;
|
|
||||||
using api.Features.Auth.Common;
|
|
||||||
using FastEndpoints;
|
using FastEndpoints;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TrackQrApi.Data;
|
||||||
|
using TrackQrApi.Features.Assets.Services;
|
||||||
|
using TrackQrApi.Features.Auth.Common;
|
||||||
|
|
||||||
namespace api.Features.Assets.Endpoints;
|
namespace TrackQrApi.Features.Assets.Endpoints;
|
||||||
|
|
||||||
public class DeleteAssetRequest
|
public class DeleteAssetRequest
|
||||||
{
|
{
|
||||||
@@ -27,7 +27,8 @@ public class DeleteAssetEndpoint(AppDbContext db, IAssetStorageService storage)
|
|||||||
|
|
||||||
var asset = await db.Assets
|
var asset = await db.Assets
|
||||||
.Include(a => a.Workspace)
|
.Include(a => a.Workspace)
|
||||||
.FirstOrDefaultAsync(a => a.Id == req.Id && a.WorkspaceId == req.WorkspaceId && a.Workspace.OwnerUserId == userId, ct);
|
.FirstOrDefaultAsync(
|
||||||
|
a => a.Id == req.Id && a.WorkspaceId == req.WorkspaceId && a.Workspace.OwnerUserId == userId, ct);
|
||||||
|
|
||||||
if (asset is null)
|
if (asset is null)
|
||||||
{
|
{
|
||||||
@@ -55,6 +56,6 @@ public class DeleteAssetEndpoint(AppDbContext db, IAssetStorageService storage)
|
|||||||
db.Assets.Remove(asset);
|
db.Assets.Remove(asset);
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
await HttpContext.Response.SendAsync(new MessageResponse("Asset deleted"), 200, cancellation: ct);
|
await HttpContext.Response.SendAsync(new MessageResponse("Asset deleted"), cancellation: ct);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
using api.Data;
|
|
||||||
using api.Features.Assets.Services;
|
|
||||||
using api.Features.Auth.Common;
|
|
||||||
using FastEndpoints;
|
using FastEndpoints;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TrackQrApi.Data;
|
||||||
|
using TrackQrApi.Features.Assets.Services;
|
||||||
|
using TrackQrApi.Features.Auth.Common;
|
||||||
|
|
||||||
namespace api.Features.Assets.Endpoints;
|
namespace TrackQrApi.Features.Assets.Endpoints;
|
||||||
|
|
||||||
public class GetAssetRequest
|
public class GetAssetRequest
|
||||||
{
|
{
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using api.Data;
|
|
||||||
using api.Features.Assets.Common;
|
|
||||||
using api.Features.Assets.Services;
|
|
||||||
using api.Features.Auth.Common;
|
|
||||||
using FastEndpoints;
|
using FastEndpoints;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TrackQrApi.Data;
|
||||||
|
using TrackQrApi.Features.Assets.Common;
|
||||||
|
using TrackQrApi.Features.Assets.Services;
|
||||||
|
using TrackQrApi.Features.Auth.Common;
|
||||||
|
|
||||||
namespace api.Features.Assets.Endpoints;
|
namespace TrackQrApi.Features.Assets.Endpoints;
|
||||||
|
|
||||||
public class ListAssetsRequest
|
public class ListAssetsRequest
|
||||||
{
|
{
|
||||||
@@ -52,6 +52,6 @@ public class ListAssetsEndpoint(AppDbContext db, IAssetStorageService storage)
|
|||||||
))
|
))
|
||||||
);
|
);
|
||||||
|
|
||||||
await HttpContext.Response.SendAsync(response, 200, cancellation: ct);
|
await HttpContext.Response.SendAsync(response, cancellation: ct);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using api.Data;
|
|
||||||
using api.Features.Assets.Common;
|
|
||||||
using api.Features.Assets.Services;
|
|
||||||
using api.Features.Auth.Common;
|
|
||||||
using api.Models;
|
|
||||||
using FastEndpoints;
|
using FastEndpoints;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TrackQrApi.Data;
|
||||||
|
using TrackQrApi.Features.Assets.Common;
|
||||||
|
using TrackQrApi.Features.Assets.Services;
|
||||||
|
using TrackQrApi.Features.Auth.Common;
|
||||||
|
using TrackQrApi.Models;
|
||||||
|
|
||||||
namespace api.Features.Assets.Endpoints;
|
namespace TrackQrApi.Features.Assets.Endpoints;
|
||||||
|
|
||||||
public class UploadAssetRequest
|
public class UploadAssetRequest
|
||||||
{
|
{
|
||||||
@@ -39,9 +39,8 @@ public class UploadAssetEndpoint(AppDbContext db, IAssetStorageService storage)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get file from form
|
// Get file from form
|
||||||
IFormFile? file = req.File;
|
var file = req.File;
|
||||||
if (file is null)
|
if (file is null)
|
||||||
{
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
file = HttpContext.Request.Form.Files.FirstOrDefault();
|
file = HttpContext.Request.Form.Files.FirstOrDefault();
|
||||||
@@ -50,7 +49,6 @@ public class UploadAssetEndpoint(AppDbContext db, IAssetStorageService storage)
|
|||||||
{
|
{
|
||||||
// Form access failed - no file uploaded
|
// Form access failed - no file uploaded
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (file is null || file.Length == 0)
|
if (file is null || file.Length == 0)
|
||||||
{
|
{
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
namespace api.Features.Assets.Services;
|
namespace TrackQrApi.Features.Assets.Services;
|
||||||
|
|
||||||
public interface IAssetStorageService
|
public interface IAssetStorageService
|
||||||
{
|
{
|
||||||
@@ -19,10 +19,7 @@ public class LocalAssetStorageService : IAssetStorageService
|
|||||||
_basePath = configuration["Storage:LocalPath"] ?? Path.Combine(Directory.GetCurrentDirectory(), "uploads");
|
_basePath = configuration["Storage:LocalPath"] ?? Path.Combine(Directory.GetCurrentDirectory(), "uploads");
|
||||||
|
|
||||||
// Ensure directory exists
|
// Ensure directory exists
|
||||||
if (!Directory.Exists(_basePath))
|
if (!Directory.Exists(_basePath)) Directory.CreateDirectory(_basePath);
|
||||||
{
|
|
||||||
Directory.CreateDirectory(_basePath);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<string> StoreAsync(Stream stream, string filename, string contentType)
|
public async Task<string> StoreAsync(Stream stream, string filename, string contentType)
|
||||||
@@ -44,10 +41,7 @@ public class LocalAssetStorageService : IAssetStorageService
|
|||||||
{
|
{
|
||||||
var filePath = Path.Combine(_basePath, storageKey);
|
var filePath = Path.Combine(_basePath, storageKey);
|
||||||
|
|
||||||
if (!File.Exists(filePath))
|
if (!File.Exists(filePath)) return Task.FromResult<(Stream, string)?>(null);
|
||||||
{
|
|
||||||
return Task.FromResult<(Stream, string)?>(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read);
|
var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read);
|
||||||
var contentType = GetContentType(storageKey);
|
var contentType = GetContentType(storageKey);
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
namespace api.Features.Auth.Common;
|
namespace TrackQrApi.Features.Auth.Common;
|
||||||
|
|
||||||
public record AuthResponse(
|
public record AuthResponse(
|
||||||
string Token,
|
string Token,
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using api.Data;
|
|
||||||
using api.Features.Auth.Common;
|
|
||||||
using FastEndpoints;
|
using FastEndpoints;
|
||||||
using FluentValidation;
|
using FluentValidation;
|
||||||
|
using TrackQrApi.Data;
|
||||||
|
using TrackQrApi.Features.Auth.Common;
|
||||||
|
|
||||||
namespace api.Features.Auth.Endpoints;
|
namespace TrackQrApi.Features.Auth.Endpoints;
|
||||||
|
|
||||||
public class ChangePasswordRequest
|
public class ChangePasswordRequest
|
||||||
{
|
{
|
||||||
@@ -46,7 +46,8 @@ public class ChangePasswordEndpoint(AppDbContext db) : Endpoint<ChangePasswordRe
|
|||||||
// Verify current password
|
// Verify current password
|
||||||
if (!BCrypt.Net.BCrypt.Verify(req.CurrentPassword, user.PasswordHash))
|
if (!BCrypt.Net.BCrypt.Verify(req.CurrentPassword, user.PasswordHash))
|
||||||
{
|
{
|
||||||
await HttpContext.Response.SendAsync(new MessageResponse("Current password is incorrect"), 400, cancellation: ct);
|
await HttpContext.Response.SendAsync(new MessageResponse("Current password is incorrect"), 400,
|
||||||
|
cancellation: ct);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using api.Data;
|
|
||||||
using api.Features.Auth.Common;
|
|
||||||
using FastEndpoints;
|
using FastEndpoints;
|
||||||
using FluentValidation;
|
using FluentValidation;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TrackQrApi.Data;
|
||||||
|
using TrackQrApi.Features.Auth.Common;
|
||||||
|
|
||||||
namespace api.Features.Auth.Endpoints;
|
namespace TrackQrApi.Features.Auth.Endpoints;
|
||||||
|
|
||||||
public class DeleteAccountRequest
|
public class DeleteAccountRequest
|
||||||
{
|
{
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
using api.Data;
|
|
||||||
using api.Features.Auth.Common;
|
|
||||||
using api.Features.Email.Services;
|
|
||||||
using api.Models;
|
|
||||||
using FastEndpoints;
|
using FastEndpoints;
|
||||||
using FluentValidation;
|
using FluentValidation;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TrackQrApi.Data;
|
||||||
|
using TrackQrApi.Features.Auth.Common;
|
||||||
|
using TrackQrApi.Features.Email.Services;
|
||||||
|
using TrackQrApi.Models;
|
||||||
|
|
||||||
namespace api.Features.Auth.Endpoints;
|
namespace TrackQrApi.Features.Auth.Endpoints;
|
||||||
|
|
||||||
public class ForgotPasswordRequest
|
public class ForgotPasswordRequest
|
||||||
{
|
{
|
||||||
@@ -46,10 +46,7 @@ public class ForgotPasswordEndpoint(AppDbContext db, IEmailService emailService)
|
|||||||
.Where(t => t.UserId == user.Id && !t.Used)
|
.Where(t => t.UserId == user.Id && !t.Used)
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
|
|
||||||
foreach (var token in existingTokens)
|
foreach (var token in existingTokens) token.Used = true;
|
||||||
{
|
|
||||||
token.Used = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate new token
|
// Generate new token
|
||||||
var resetToken = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32))
|
var resetToken = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32))
|
||||||
@@ -87,7 +84,6 @@ public class ForgotPasswordEndpoint(AppDbContext db, IEmailService emailService)
|
|||||||
// Always return success to prevent email enumeration
|
// Always return success to prevent email enumeration
|
||||||
await HttpContext.Response.SendAsync(
|
await HttpContext.Response.SendAsync(
|
||||||
new MessageResponse("If the email exists, a reset link will be sent"),
|
new MessageResponse("If the email exists, a reset link will be sent"),
|
||||||
200,
|
|
||||||
cancellation: ct);
|
cancellation: ct);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using api.Data;
|
|
||||||
using FastEndpoints;
|
using FastEndpoints;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TrackQrApi.Data;
|
||||||
|
|
||||||
namespace api.Features.Auth.Endpoints;
|
namespace TrackQrApi.Features.Auth.Endpoints;
|
||||||
|
|
||||||
public record ProfileResponse(
|
public record ProfileResponse(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
@@ -1,16 +1,17 @@
|
|||||||
using System.IdentityModel.Tokens.Jwt;
|
using System.IdentityModel.Tokens.Jwt;
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using api.Data;
|
|
||||||
using api.Features.Auth.Common;
|
|
||||||
using api.Features.Auth.Settings;
|
|
||||||
using FastEndpoints;
|
using FastEndpoints;
|
||||||
|
using FastEndpoints.Security;
|
||||||
using FluentValidation;
|
using FluentValidation;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using Microsoft.IdentityModel.Tokens;
|
using Microsoft.IdentityModel.Tokens;
|
||||||
|
using TrackQrApi.Data;
|
||||||
|
using TrackQrApi.Features.Auth.Common;
|
||||||
|
using TrackQrApi.Features.Auth.Settings;
|
||||||
|
|
||||||
namespace api.Features.Auth.Endpoints;
|
namespace TrackQrApi.Features.Auth.Endpoints;
|
||||||
|
|
||||||
public class LoginRequest
|
public class LoginRequest
|
||||||
{
|
{
|
||||||
@@ -50,37 +51,36 @@ public class LoginEndpoint(AppDbContext db, IOptions<JwtSettings> jwtSettings)
|
|||||||
|
|
||||||
if (user == null || !BCrypt.Net.BCrypt.Verify(req.Password, user.PasswordHash))
|
if (user == null || !BCrypt.Net.BCrypt.Verify(req.Password, user.PasswordHash))
|
||||||
{
|
{
|
||||||
await HttpContext.Response.SendAsync(new MessageResponse("Invalid email or password"), 401, cancellation: ct);
|
await HttpContext.Response.SendAsync(new MessageResponse("Invalid email or password"), 401,
|
||||||
|
cancellation: ct);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Logger.LogInformation("User logged in: {Email}", normalizedEmail);
|
Logger.LogInformation("User logged in: {Email}", normalizedEmail);
|
||||||
|
|
||||||
var expiresAt = DateTime.UtcNow.AddMinutes(_jwtSettings.ExpirationMinutes);
|
var expiresAt = DateTime.UtcNow.AddMinutes(_jwtSettings.ExpirationMinutes);
|
||||||
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtSettings.Secret));
|
|
||||||
var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
|
|
||||||
|
|
||||||
var claims = new[]
|
var jwtToken = JwtBearer.CreateToken(o =>
|
||||||
{
|
{
|
||||||
new Claim(JwtRegisteredClaimNames.Sub, user.Id.ToString()),
|
o.SigningKey = _jwtSettings.Secret;
|
||||||
new Claim(JwtRegisteredClaimNames.Email, user.Email),
|
o.Issuer = _jwtSettings.Issuer;
|
||||||
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
|
o.Audience = _jwtSettings.Audience;
|
||||||
};
|
o.ExpireAt = expiresAt;
|
||||||
|
//o.User.Roles.Add("Manager", "Auditor");
|
||||||
var token = new JwtSecurityToken(
|
o.User.Claims.Add(
|
||||||
issuer: _jwtSettings.Issuer,
|
new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
|
||||||
audience: _jwtSettings.Audience,
|
new Claim(JwtRegisteredClaimNames.Sub, user.Id.ToString()),
|
||||||
claims: claims,
|
new Claim(JwtRegisteredClaimNames.Email, user.Email),
|
||||||
expires: expiresAt,
|
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
|
||||||
signingCredentials: credentials
|
);
|
||||||
);
|
});
|
||||||
|
|
||||||
var response = new AuthResponse(
|
var response = new AuthResponse(
|
||||||
Token: new JwtSecurityTokenHandler().WriteToken(token),
|
jwtToken,
|
||||||
ExpiresAt: expiresAt,
|
expiresAt,
|
||||||
User: new UserInfo(user.Id, user.Email, user.VerifiedAt.HasValue)
|
new UserInfo(user.Id, user.Email, user.VerifiedAt.HasValue)
|
||||||
);
|
);
|
||||||
|
|
||||||
await HttpContext.Response.SendAsync(response, 200, cancellation: ct);
|
await HttpContext.Response.SendAsync(response, cancellation: ct);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,18 +2,18 @@ using System.IdentityModel.Tokens.Jwt;
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using api.Data;
|
|
||||||
using api.Features.Auth.Common;
|
|
||||||
using api.Features.Auth.Settings;
|
|
||||||
using api.Features.Email.Services;
|
|
||||||
using api.Models;
|
|
||||||
using FastEndpoints;
|
using FastEndpoints;
|
||||||
using FluentValidation;
|
using FluentValidation;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using Microsoft.IdentityModel.Tokens;
|
using Microsoft.IdentityModel.Tokens;
|
||||||
|
using TrackQrApi.Data;
|
||||||
|
using TrackQrApi.Features.Auth.Common;
|
||||||
|
using TrackQrApi.Features.Auth.Settings;
|
||||||
|
using TrackQrApi.Features.Email.Services;
|
||||||
|
using TrackQrApi.Models;
|
||||||
|
|
||||||
namespace api.Features.Auth.Endpoints;
|
namespace TrackQrApi.Features.Auth.Endpoints;
|
||||||
|
|
||||||
public class RegisterRequest
|
public class RegisterRequest
|
||||||
{
|
{
|
||||||
@@ -55,7 +55,8 @@ public class RegisterEndpoint(AppDbContext db, IOptions<JwtSettings> jwtSettings
|
|||||||
|
|
||||||
if (await db.Users.AnyAsync(u => u.Email == normalizedEmail, ct))
|
if (await db.Users.AnyAsync(u => u.Email == normalizedEmail, ct))
|
||||||
{
|
{
|
||||||
await HttpContext.Response.SendAsync(new MessageResponse("Email already registered"), 409, cancellation: ct);
|
await HttpContext.Response.SendAsync(new MessageResponse("Email already registered"), 409,
|
||||||
|
cancellation: ct);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,17 +123,17 @@ public class RegisterEndpoint(AppDbContext db, IOptions<JwtSettings> jwtSettings
|
|||||||
};
|
};
|
||||||
|
|
||||||
var token = new JwtSecurityToken(
|
var token = new JwtSecurityToken(
|
||||||
issuer: _jwtSettings.Issuer,
|
_jwtSettings.Issuer,
|
||||||
audience: _jwtSettings.Audience,
|
_jwtSettings.Audience,
|
||||||
claims: claims,
|
claims,
|
||||||
expires: expiresAt,
|
expires: expiresAt,
|
||||||
signingCredentials: credentials
|
signingCredentials: credentials
|
||||||
);
|
);
|
||||||
|
|
||||||
return new AuthResponse(
|
return new AuthResponse(
|
||||||
Token: new JwtSecurityTokenHandler().WriteToken(token),
|
new JwtSecurityTokenHandler().WriteToken(token),
|
||||||
ExpiresAt: expiresAt,
|
expiresAt,
|
||||||
User: new UserInfo(user.Id, user.Email, user.VerifiedAt.HasValue)
|
new UserInfo(user.Id, user.Email, user.VerifiedAt.HasValue)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
using api.Data;
|
|
||||||
using api.Features.Auth.Common;
|
|
||||||
using api.Features.Email.Services;
|
|
||||||
using api.Models;
|
|
||||||
using FastEndpoints;
|
using FastEndpoints;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TrackQrApi.Data;
|
||||||
|
using TrackQrApi.Features.Auth.Common;
|
||||||
|
using TrackQrApi.Features.Email.Services;
|
||||||
|
using TrackQrApi.Models;
|
||||||
|
|
||||||
namespace api.Features.Auth.Endpoints;
|
namespace TrackQrApi.Features.Auth.Endpoints;
|
||||||
|
|
||||||
public class ResendVerificationEndpoint(AppDbContext db, IEmailService emailService) : EndpointWithoutRequest
|
public class ResendVerificationEndpoint(AppDbContext db, IEmailService emailService) : EndpointWithoutRequest
|
||||||
{
|
{
|
||||||
@@ -29,7 +29,8 @@ public class ResendVerificationEndpoint(AppDbContext db, IEmailService emailServ
|
|||||||
|
|
||||||
if (user.VerifiedAt != null)
|
if (user.VerifiedAt != null)
|
||||||
{
|
{
|
||||||
await HttpContext.Response.SendAsync(new MessageResponse("Email is already verified"), 400, cancellation: ct);
|
await HttpContext.Response.SendAsync(new MessageResponse("Email is already verified"), 400,
|
||||||
|
cancellation: ct);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
using api.Data;
|
|
||||||
using api.Features.Auth.Common;
|
|
||||||
using FastEndpoints;
|
using FastEndpoints;
|
||||||
using FluentValidation;
|
using FluentValidation;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TrackQrApi.Data;
|
||||||
|
using TrackQrApi.Features.Auth.Common;
|
||||||
|
|
||||||
namespace api.Features.Auth.Endpoints;
|
namespace TrackQrApi.Features.Auth.Endpoints;
|
||||||
|
|
||||||
public class ResetPasswordRequest
|
public class ResetPasswordRequest
|
||||||
{
|
{
|
||||||
@@ -82,7 +82,6 @@ public class ResetPasswordEndpoint(AppDbContext db)
|
|||||||
|
|
||||||
await HttpContext.Response.SendAsync(
|
await HttpContext.Response.SendAsync(
|
||||||
new MessageResponse("Password has been reset successfully"),
|
new MessageResponse("Password has been reset successfully"),
|
||||||
200,
|
|
||||||
cancellation: ct);
|
cancellation: ct);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using api.Data;
|
|
||||||
using api.Features.Auth.Common;
|
|
||||||
using FastEndpoints;
|
using FastEndpoints;
|
||||||
using FluentValidation;
|
using FluentValidation;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TrackQrApi.Data;
|
||||||
|
using TrackQrApi.Features.Auth.Common;
|
||||||
|
|
||||||
namespace api.Features.Auth.Endpoints;
|
namespace TrackQrApi.Features.Auth.Endpoints;
|
||||||
|
|
||||||
public class UpdateProfileRequest
|
public class UpdateProfileRequest
|
||||||
{
|
{
|
||||||
@@ -46,7 +46,8 @@ public class UpdateProfileEndpoint(AppDbContext db) : Endpoint<UpdateProfileRequ
|
|||||||
var emailExists = await db.Users.AnyAsync(u => u.Email == req.Email && u.Id != userId, ct);
|
var emailExists = await db.Users.AnyAsync(u => u.Email == req.Email && u.Id != userId, ct);
|
||||||
if (emailExists)
|
if (emailExists)
|
||||||
{
|
{
|
||||||
await HttpContext.Response.SendAsync(new MessageResponse("Email is already in use"), 409, cancellation: ct);
|
await HttpContext.Response.SendAsync(new MessageResponse("Email is already in use"), 409,
|
||||||
|
cancellation: ct);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
using api.Data;
|
|
||||||
using api.Features.Auth.Common;
|
|
||||||
using FastEndpoints;
|
using FastEndpoints;
|
||||||
using FluentValidation;
|
using FluentValidation;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TrackQrApi.Data;
|
||||||
|
using TrackQrApi.Features.Auth.Common;
|
||||||
|
|
||||||
namespace api.Features.Auth.Endpoints;
|
namespace TrackQrApi.Features.Auth.Endpoints;
|
||||||
|
|
||||||
public class VerifyEmailRequest
|
public class VerifyEmailRequest
|
||||||
{
|
{
|
||||||
@@ -35,7 +35,8 @@ public class VerifyEmailEndpoint(AppDbContext db) : Endpoint<VerifyEmailRequest>
|
|||||||
|
|
||||||
if (token == null)
|
if (token == null)
|
||||||
{
|
{
|
||||||
await HttpContext.Response.SendAsync(new MessageResponse("Invalid verification token"), 400, cancellation: ct);
|
await HttpContext.Response.SendAsync(new MessageResponse("Invalid verification token"), 400,
|
||||||
|
cancellation: ct);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,7 +44,8 @@ public class VerifyEmailEndpoint(AppDbContext db) : Endpoint<VerifyEmailRequest>
|
|||||||
{
|
{
|
||||||
db.EmailVerificationTokens.Remove(token);
|
db.EmailVerificationTokens.Remove(token);
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
await HttpContext.Response.SendAsync(new MessageResponse("Verification token has expired"), 400, cancellation: ct);
|
await HttpContext.Response.SendAsync(new MessageResponse("Verification token has expired"), 400,
|
||||||
|
cancellation: ct);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
namespace api.Features.Auth.Settings;
|
namespace TrackQrApi.Features.Auth.Settings;
|
||||||
|
|
||||||
public class JwtSettings
|
public class JwtSettings
|
||||||
{
|
{
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
namespace api.Features.Billing.Common;
|
namespace TrackQrApi.Features.Billing.Common;
|
||||||
|
|
||||||
public record CheckoutSessionRequest(
|
public record CheckoutSessionRequest(
|
||||||
Guid WorkspaceId,
|
Guid WorkspaceId,
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using api.Data;
|
|
||||||
using api.Features.Auth.Common;
|
|
||||||
using api.Features.Billing.Common;
|
|
||||||
using api.Features.Billing.Services;
|
|
||||||
using api.Models;
|
|
||||||
using FastEndpoints;
|
using FastEndpoints;
|
||||||
using FluentValidation;
|
using FluentValidation;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TrackQrApi.Data;
|
||||||
|
using TrackQrApi.Features.Auth.Common;
|
||||||
|
using TrackQrApi.Features.Billing.Common;
|
||||||
|
using TrackQrApi.Features.Billing.Services;
|
||||||
|
using TrackQrApi.Models;
|
||||||
|
|
||||||
namespace api.Features.Billing.Endpoints;
|
namespace TrackQrApi.Features.Billing.Endpoints;
|
||||||
|
|
||||||
public class CreateCheckoutSessionValidator : Validator<CheckoutSessionRequest>
|
public class CreateCheckoutSessionValidator : Validator<CheckoutSessionRequest>
|
||||||
{
|
{
|
||||||
@@ -56,7 +56,8 @@ public class CreateCheckoutSessionEndpoint(AppDbContext db, IStripeService strip
|
|||||||
if (!string.IsNullOrEmpty(workspace.StripeSubscriptionId))
|
if (!string.IsNullOrEmpty(workspace.StripeSubscriptionId))
|
||||||
{
|
{
|
||||||
await HttpContext.Response.SendAsync(
|
await HttpContext.Response.SendAsync(
|
||||||
new MessageResponse("Workspace already has an active subscription. Use the billing portal to manage it."),
|
new MessageResponse(
|
||||||
|
"Workspace already has an active subscription. Use the billing portal to manage it."),
|
||||||
400,
|
400,
|
||||||
cancellation: ct);
|
cancellation: ct);
|
||||||
return;
|
return;
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using api.Features.Auth.Common;
|
|
||||||
using api.Features.Billing.Common;
|
|
||||||
using api.Features.Billing.Services;
|
|
||||||
using FastEndpoints;
|
using FastEndpoints;
|
||||||
using FluentValidation;
|
using FluentValidation;
|
||||||
|
using TrackQrApi.Features.Auth.Common;
|
||||||
|
using TrackQrApi.Features.Billing.Common;
|
||||||
|
using TrackQrApi.Features.Billing.Services;
|
||||||
|
|
||||||
namespace api.Features.Billing.Endpoints;
|
namespace TrackQrApi.Features.Billing.Endpoints;
|
||||||
|
|
||||||
public class CreatePortalSessionValidator : Validator<PortalSessionRequest>
|
public class CreatePortalSessionValidator : Validator<PortalSessionRequest>
|
||||||
{
|
{
|
||||||
@@ -1,12 +1,13 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using api.Data;
|
|
||||||
using api.Features.Auth.Common;
|
|
||||||
using api.Features.Billing.Common;
|
|
||||||
using api.Features.Billing.Services;
|
|
||||||
using FastEndpoints;
|
using FastEndpoints;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TrackQrApi.Data;
|
||||||
|
using TrackQrApi.Features.Auth.Common;
|
||||||
|
using TrackQrApi.Features.Billing.Common;
|
||||||
|
using TrackQrApi.Features.Billing.Services;
|
||||||
|
using TrackQrApi.Models;
|
||||||
|
|
||||||
namespace api.Features.Billing.Endpoints;
|
namespace TrackQrApi.Features.Billing.Endpoints;
|
||||||
|
|
||||||
public class GetSubscriptionRequest
|
public class GetSubscriptionRequest
|
||||||
{
|
{
|
||||||
@@ -34,7 +35,7 @@ public class GetSubscriptionEndpoint(AppDbContext db, IStripeService stripeServi
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var isActive = workspace.Plan != Models.WorkspacePlan.Free;
|
var isActive = workspace.Plan != WorkspacePlan.Free;
|
||||||
var cancelAtPeriodEnd = false;
|
var cancelAtPeriodEnd = false;
|
||||||
|
|
||||||
// Get live subscription status from Stripe if exists
|
// Get live subscription status from Stripe if exists
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
using api.Features.Billing.Services;
|
|
||||||
using api.Features.Billing.Settings;
|
|
||||||
using FastEndpoints;
|
using FastEndpoints;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using Stripe;
|
using Stripe;
|
||||||
|
using Stripe.Checkout;
|
||||||
|
using TrackQrApi.Features.Billing.Services;
|
||||||
|
using TrackQrApi.Features.Billing.Settings;
|
||||||
|
|
||||||
namespace api.Features.Billing.Endpoints;
|
namespace TrackQrApi.Features.Billing.Endpoints;
|
||||||
|
|
||||||
public class StripeWebhookEndpoint(
|
public class StripeWebhookEndpoint(
|
||||||
IStripeService stripeService,
|
IStripeService stripeService,
|
||||||
@@ -38,27 +39,20 @@ public class StripeWebhookEndpoint(
|
|||||||
switch (stripeEvent.Type)
|
switch (stripeEvent.Type)
|
||||||
{
|
{
|
||||||
case "checkout.session.completed":
|
case "checkout.session.completed":
|
||||||
var session = stripeEvent.Data.Object as Stripe.Checkout.Session;
|
var session = stripeEvent.Data.Object as Session;
|
||||||
if (session != null)
|
if (session != null) await stripeService.HandleCheckoutCompletedAsync(session, ct);
|
||||||
{
|
|
||||||
await stripeService.HandleCheckoutCompletedAsync(session, ct);
|
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "customer.subscription.updated":
|
case "customer.subscription.updated":
|
||||||
var updatedSubscription = stripeEvent.Data.Object as Subscription;
|
var updatedSubscription = stripeEvent.Data.Object as Subscription;
|
||||||
if (updatedSubscription != null)
|
if (updatedSubscription != null)
|
||||||
{
|
|
||||||
await stripeService.HandleSubscriptionUpdatedAsync(updatedSubscription, ct);
|
await stripeService.HandleSubscriptionUpdatedAsync(updatedSubscription, ct);
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "customer.subscription.deleted":
|
case "customer.subscription.deleted":
|
||||||
var deletedSubscription = stripeEvent.Data.Object as Subscription;
|
var deletedSubscription = stripeEvent.Data.Object as Subscription;
|
||||||
if (deletedSubscription != null)
|
if (deletedSubscription != null)
|
||||||
{
|
|
||||||
await stripeService.HandleSubscriptionDeletedAsync(deletedSubscription, ct);
|
await stripeService.HandleSubscriptionDeletedAsync(deletedSubscription, ct);
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "invoice.payment_failed":
|
case "invoice.payment_failed":
|
||||||
@@ -76,7 +70,8 @@ public class StripeWebhookEndpoint(
|
|||||||
catch (StripeException ex)
|
catch (StripeException ex)
|
||||||
{
|
{
|
||||||
logger.LogError(ex, "Stripe webhook signature verification failed");
|
logger.LogError(ex, "Stripe webhook signature verification failed");
|
||||||
await HttpContext.Response.SendAsync(new { error = "Webhook signature verification failed" }, 400, cancellation: ct);
|
await HttpContext.Response.SendAsync(new { error = "Webhook signature verification failed" }, 400,
|
||||||
|
cancellation: ct);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -1,16 +1,18 @@
|
|||||||
using api.Data;
|
|
||||||
using api.Features.Billing.Settings;
|
|
||||||
using api.Models;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using Stripe;
|
using Stripe;
|
||||||
using Stripe.Checkout;
|
using Stripe.Checkout;
|
||||||
|
using TrackQrApi.Data;
|
||||||
|
using TrackQrApi.Features.Billing.Settings;
|
||||||
|
using TrackQrApi.Models;
|
||||||
|
|
||||||
namespace api.Features.Billing.Services;
|
namespace TrackQrApi.Features.Billing.Services;
|
||||||
|
|
||||||
public interface IStripeService
|
public interface IStripeService
|
||||||
{
|
{
|
||||||
Task<string> CreateCheckoutSessionAsync(Guid userId, Guid workspaceId, WorkspacePlan plan, string successUrl, string cancelUrl, CancellationToken ct = default);
|
Task<string> CreateCheckoutSessionAsync(Guid userId, Guid workspaceId, WorkspacePlan plan, string successUrl,
|
||||||
|
string cancelUrl, CancellationToken ct = default);
|
||||||
|
|
||||||
Task<string> CreateCustomerPortalSessionAsync(Guid userId, string returnUrl, CancellationToken ct = default);
|
Task<string> CreateCustomerPortalSessionAsync(Guid userId, string returnUrl, CancellationToken ct = default);
|
||||||
Task<Subscription?> GetSubscriptionAsync(string subscriptionId, CancellationToken ct = default);
|
Task<Subscription?> GetSubscriptionAsync(string subscriptionId, CancellationToken ct = default);
|
||||||
Task CancelSubscriptionAsync(string subscriptionId, CancellationToken ct = default);
|
Task CancelSubscriptionAsync(string subscriptionId, CancellationToken ct = default);
|
||||||
@@ -23,9 +25,9 @@ public interface IStripeService
|
|||||||
|
|
||||||
public class StripeService : IStripeService
|
public class StripeService : IStripeService
|
||||||
{
|
{
|
||||||
|
private readonly ILogger<StripeService> _logger;
|
||||||
private readonly IServiceScopeFactory _scopeFactory;
|
private readonly IServiceScopeFactory _scopeFactory;
|
||||||
private readonly StripeSettings _settings;
|
private readonly StripeSettings _settings;
|
||||||
private readonly ILogger<StripeService> _logger;
|
|
||||||
|
|
||||||
public StripeService(
|
public StripeService(
|
||||||
IServiceScopeFactory scopeFactory,
|
IServiceScopeFactory scopeFactory,
|
||||||
@@ -51,7 +53,7 @@ public class StripeService : IStripeService
|
|||||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
|
||||||
var user = await db.Users.FindAsync([userId], ct)
|
var user = await db.Users.FindAsync([userId], ct)
|
||||||
?? throw new InvalidOperationException("User not found");
|
?? throw new InvalidOperationException("User not found");
|
||||||
|
|
||||||
// Get or create Stripe customer
|
// Get or create Stripe customer
|
||||||
var customerId = user.StripeCustomerId;
|
var customerId = user.StripeCustomerId;
|
||||||
@@ -73,10 +75,7 @@ public class StripeService : IStripeService
|
|||||||
}
|
}
|
||||||
|
|
||||||
var priceId = GetPriceIdForPlan(plan);
|
var priceId = GetPriceIdForPlan(plan);
|
||||||
if (string.IsNullOrEmpty(priceId))
|
if (string.IsNullOrEmpty(priceId)) throw new InvalidOperationException($"No price configured for plan: {plan}");
|
||||||
{
|
|
||||||
throw new InvalidOperationException($"No price configured for plan: {plan}");
|
|
||||||
}
|
|
||||||
|
|
||||||
var sessionService = new SessionService();
|
var sessionService = new SessionService();
|
||||||
var session = await sessionService.CreateAsync(new SessionCreateOptions
|
var session = await sessionService.CreateAsync(new SessionCreateOptions
|
||||||
@@ -122,12 +121,10 @@ public class StripeService : IStripeService
|
|||||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
|
||||||
var user = await db.Users.FindAsync([userId], ct)
|
var user = await db.Users.FindAsync([userId], ct)
|
||||||
?? throw new InvalidOperationException("User not found");
|
?? throw new InvalidOperationException("User not found");
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(user.StripeCustomerId))
|
if (string.IsNullOrEmpty(user.StripeCustomerId))
|
||||||
{
|
|
||||||
throw new InvalidOperationException("User has no Stripe customer");
|
throw new InvalidOperationException("User has no Stripe customer");
|
||||||
}
|
|
||||||
|
|
||||||
var sessionService = new Stripe.BillingPortal.SessionService();
|
var sessionService = new Stripe.BillingPortal.SessionService();
|
||||||
var session = await sessionService.CreateAsync(new Stripe.BillingPortal.SessionCreateOptions
|
var session = await sessionService.CreateAsync(new Stripe.BillingPortal.SessionCreateOptions
|
||||||
@@ -202,10 +199,7 @@ public class StripeService : IStripeService
|
|||||||
if (!string.IsNullOrEmpty(session.SubscriptionId))
|
if (!string.IsNullOrEmpty(session.SubscriptionId))
|
||||||
{
|
{
|
||||||
var subscription = await GetSubscriptionAsync(session.SubscriptionId, ct);
|
var subscription = await GetSubscriptionAsync(session.SubscriptionId, ct);
|
||||||
if (subscription != null)
|
if (subscription != null) workspace.SubscriptionEndsAt = subscription.CurrentPeriodEnd;
|
||||||
{
|
|
||||||
workspace.SubscriptionEndsAt = subscription.CurrentPeriodEnd;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
@@ -247,11 +241,9 @@ public class StripeService : IStripeService
|
|||||||
|
|
||||||
// Handle cancellation at period end
|
// Handle cancellation at period end
|
||||||
if (subscription.CancelAtPeriodEnd)
|
if (subscription.CancelAtPeriodEnd)
|
||||||
{
|
|
||||||
_logger.LogInformation(
|
_logger.LogInformation(
|
||||||
"Workspace {WorkspaceId} subscription will cancel at {EndDate}",
|
"Workspace {WorkspaceId} subscription will cancel at {EndDate}",
|
||||||
workspace.Id, subscription.CurrentPeriodEnd);
|
workspace.Id, subscription.CurrentPeriodEnd);
|
||||||
}
|
|
||||||
|
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
namespace api.Features.Billing.Settings;
|
namespace TrackQrApi.Features.Billing.Settings;
|
||||||
|
|
||||||
public class StripeSettings
|
public class StripeSettings
|
||||||
{
|
{
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
namespace api.Features.Domains.Common;
|
namespace TrackQrApi.Features.Domains.Common;
|
||||||
|
|
||||||
public record DomainResponse(
|
public record DomainResponse(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
@@ -1,15 +1,15 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
using api.Data;
|
|
||||||
using api.Features.Auth.Common;
|
|
||||||
using api.Features.Domains.Common;
|
|
||||||
using api.Features.Plans.Services;
|
|
||||||
using api.Models;
|
|
||||||
using FastEndpoints;
|
using FastEndpoints;
|
||||||
using FluentValidation;
|
using FluentValidation;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TrackQrApi.Data;
|
||||||
|
using TrackQrApi.Features.Auth.Common;
|
||||||
|
using TrackQrApi.Features.Domains.Common;
|
||||||
|
using TrackQrApi.Features.Plans.Services;
|
||||||
|
using TrackQrApi.Models;
|
||||||
|
|
||||||
namespace api.Features.Domains.Endpoints;
|
namespace TrackQrApi.Features.Domains.Endpoints;
|
||||||
|
|
||||||
public class AddDomainRequest
|
public class AddDomainRequest
|
||||||
{
|
{
|
||||||
@@ -70,7 +70,8 @@ public class AddDomainEndpoint(AppDbContext db, IPlanLimitsService planLimits)
|
|||||||
|
|
||||||
if (domainExists)
|
if (domainExists)
|
||||||
{
|
{
|
||||||
await HttpContext.Response.SendAsync(new MessageResponse("Domain is already registered"), 409, cancellation: ct);
|
await HttpContext.Response.SendAsync(new MessageResponse("Domain is already registered"), 409,
|
||||||
|
cancellation: ct);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using api.Data;
|
|
||||||
using api.Features.Auth.Common;
|
|
||||||
using FastEndpoints;
|
using FastEndpoints;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TrackQrApi.Data;
|
||||||
|
using TrackQrApi.Features.Auth.Common;
|
||||||
|
|
||||||
namespace api.Features.Domains.Endpoints;
|
namespace TrackQrApi.Features.Domains.Endpoints;
|
||||||
|
|
||||||
public class DeleteDomainRequest
|
public class DeleteDomainRequest
|
||||||
{
|
{
|
||||||
@@ -26,7 +26,8 @@ public class DeleteDomainEndpoint(AppDbContext db)
|
|||||||
|
|
||||||
var domain = await db.Domains
|
var domain = await db.Domains
|
||||||
.Include(d => d.Workspace)
|
.Include(d => d.Workspace)
|
||||||
.FirstOrDefaultAsync(d => d.Id == req.Id && d.WorkspaceId == req.WorkspaceId && d.Workspace.OwnerUserId == userId, ct);
|
.FirstOrDefaultAsync(
|
||||||
|
d => d.Id == req.Id && d.WorkspaceId == req.WorkspaceId && d.Workspace.OwnerUserId == userId, ct);
|
||||||
|
|
||||||
if (domain is null)
|
if (domain is null)
|
||||||
{
|
{
|
||||||
@@ -50,6 +51,6 @@ public class DeleteDomainEndpoint(AppDbContext db)
|
|||||||
db.Domains.Remove(domain);
|
db.Domains.Remove(domain);
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
await HttpContext.Response.SendAsync(new MessageResponse("Domain deleted"), 200, cancellation: ct);
|
await HttpContext.Response.SendAsync(new MessageResponse("Domain deleted"), cancellation: ct);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using api.Data;
|
|
||||||
using api.Features.Auth.Common;
|
|
||||||
using api.Features.Domains.Common;
|
|
||||||
using FastEndpoints;
|
using FastEndpoints;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TrackQrApi.Data;
|
||||||
|
using TrackQrApi.Features.Auth.Common;
|
||||||
|
using TrackQrApi.Features.Domains.Common;
|
||||||
|
|
||||||
namespace api.Features.Domains.Endpoints;
|
namespace TrackQrApi.Features.Domains.Endpoints;
|
||||||
|
|
||||||
public class GetDomainRequest
|
public class GetDomainRequest
|
||||||
{
|
{
|
||||||
@@ -45,6 +45,6 @@ public class GetDomainEndpoint(AppDbContext db)
|
|||||||
domain.CreatedAt
|
domain.CreatedAt
|
||||||
);
|
);
|
||||||
|
|
||||||
await HttpContext.Response.SendAsync(response, 200, cancellation: ct);
|
await HttpContext.Response.SendAsync(response, cancellation: ct);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using api.Data;
|
|
||||||
using api.Features.Auth.Common;
|
|
||||||
using api.Features.Domains.Common;
|
|
||||||
using FastEndpoints;
|
using FastEndpoints;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TrackQrApi.Data;
|
||||||
|
using TrackQrApi.Features.Auth.Common;
|
||||||
|
using TrackQrApi.Features.Domains.Common;
|
||||||
|
|
||||||
namespace api.Features.Domains.Endpoints;
|
namespace TrackQrApi.Features.Domains.Endpoints;
|
||||||
|
|
||||||
public class ListDomainsRequest
|
public class ListDomainsRequest
|
||||||
{
|
{
|
||||||
@@ -48,6 +48,6 @@ public class ListDomainsEndpoint(AppDbContext db)
|
|||||||
))
|
))
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
|
|
||||||
await HttpContext.Response.SendAsync(new DomainListResponse(domains), 200, cancellation: ct);
|
await HttpContext.Response.SendAsync(new DomainListResponse(domains), cancellation: ct);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using api.Data;
|
|
||||||
using api.Features.Auth.Common;
|
|
||||||
using api.Features.Domains.Common;
|
|
||||||
using api.Models;
|
|
||||||
using FastEndpoints;
|
using FastEndpoints;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TrackQrApi.Data;
|
||||||
|
using TrackQrApi.Features.Auth.Common;
|
||||||
|
using TrackQrApi.Features.Domains.Common;
|
||||||
|
using TrackQrApi.Models;
|
||||||
|
|
||||||
namespace api.Features.Domains.Endpoints;
|
namespace TrackQrApi.Features.Domains.Endpoints;
|
||||||
|
|
||||||
public class VerifyDomainRequest
|
public class VerifyDomainRequest
|
||||||
{
|
{
|
||||||
@@ -28,7 +28,8 @@ public class VerifyDomainEndpoint(AppDbContext db)
|
|||||||
|
|
||||||
var domain = await db.Domains
|
var domain = await db.Domains
|
||||||
.Include(d => d.Workspace)
|
.Include(d => d.Workspace)
|
||||||
.FirstOrDefaultAsync(d => d.Id == req.Id && d.WorkspaceId == req.WorkspaceId && d.Workspace.OwnerUserId == userId, ct);
|
.FirstOrDefaultAsync(
|
||||||
|
d => d.Id == req.Id && d.WorkspaceId == req.WorkspaceId && d.Workspace.OwnerUserId == userId, ct);
|
||||||
|
|
||||||
if (domain is null)
|
if (domain is null)
|
||||||
{
|
{
|
||||||
@@ -46,7 +47,7 @@ public class VerifyDomainEndpoint(AppDbContext db)
|
|||||||
domain.Status.ToString(),
|
domain.Status.ToString(),
|
||||||
"Domain is already verified"
|
"Domain is already verified"
|
||||||
);
|
);
|
||||||
await HttpContext.Response.SendAsync(alreadyResponse, 200, cancellation: ct);
|
await HttpContext.Response.SendAsync(alreadyResponse, cancellation: ct);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,7 +66,7 @@ public class VerifyDomainEndpoint(AppDbContext db)
|
|||||||
domain.Status.ToString(),
|
domain.Status.ToString(),
|
||||||
"Domain verified successfully"
|
"Domain verified successfully"
|
||||||
);
|
);
|
||||||
await HttpContext.Response.SendAsync(successResponse, 200, cancellation: ct);
|
await HttpContext.Response.SendAsync(successResponse, cancellation: ct);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -76,7 +77,7 @@ public class VerifyDomainEndpoint(AppDbContext db)
|
|||||||
domain.Status.ToString(),
|
domain.Status.ToString(),
|
||||||
$"Verification failed. Please add a TXT record for _trakqr-verification.{domain.Hostname} with value: {domain.VerificationToken}"
|
$"Verification failed. Please add a TXT record for _trakqr-verification.{domain.Hostname} with value: {domain.VerificationToken}"
|
||||||
);
|
);
|
||||||
await HttpContext.Response.SendAsync(failedResponse, 200, cancellation: ct);
|
await HttpContext.Response.SendAsync(failedResponse, cancellation: ct);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,22 +1,18 @@
|
|||||||
using api.Features.Email.Templates;
|
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
using TrackQrApi.Features.Email.Templates;
|
||||||
|
|
||||||
namespace api.Features.Email.Services;
|
namespace TrackQrApi.Features.Email.Services;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Development email service that logs emails to console instead of sending them.
|
/// Development email service that logs emails to console instead of sending them.
|
||||||
/// Useful for testing without a real SMTP server.
|
/// Useful for testing without a real SMTP server.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class ConsoleEmailService : IEmailService
|
public class ConsoleEmailService(
|
||||||
|
IOptions<EmailSettings> settings,
|
||||||
|
ILogger<ConsoleEmailService> logger)
|
||||||
|
: IEmailService
|
||||||
{
|
{
|
||||||
private readonly EmailSettings _settings;
|
private readonly EmailSettings _settings = settings.Value;
|
||||||
private readonly ILogger<ConsoleEmailService> _logger;
|
|
||||||
|
|
||||||
public ConsoleEmailService(IOptions<EmailSettings> settings, ILogger<ConsoleEmailService> logger)
|
|
||||||
{
|
|
||||||
_settings = settings.Value;
|
|
||||||
_logger = logger;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task SendPasswordResetEmailAsync(string toEmail, string resetToken, CancellationToken ct = default)
|
public Task SendPasswordResetEmailAsync(string toEmail, string resetToken, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
@@ -47,14 +43,15 @@ public class ConsoleEmailService : IEmailService
|
|||||||
|
|
||||||
private void LogEmail(string toEmail, string subject, string body, string actionUrl)
|
private void LogEmail(string toEmail, string subject, string body, string actionUrl)
|
||||||
{
|
{
|
||||||
_logger.LogInformation($"""
|
logger.LogInformation($"""
|
||||||
|
|
||||||
╔══════════════════════════════════════════════════════════════╗
|
╔══════════════════════════════════════════════════════════════╗
|
||||||
║ EMAIL (Console Mode) ║
|
║ EMAIL (Console Mode) ║
|
||||||
╚══════════════════════════════════════════════════════════════╝
|
╚══════════════════════════════════════════════════════════════╝
|
||||||
To: {toEmail}
|
To: {toEmail}
|
||||||
Subject: {subject}
|
Subject: {subject}
|
||||||
Action URL: {actionUrl}
|
Action URL: {actionUrl}
|
||||||
""");
|
|
||||||
|
""");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
namespace api.Features.Email.Services;
|
namespace TrackQrApi.Features.Email.Services;
|
||||||
|
|
||||||
public interface IEmailService
|
public interface IEmailService
|
||||||
{
|
{
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Net.Mail;
|
using System.Net.Mail;
|
||||||
using api.Features.Email.Templates;
|
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
using TrackQrApi.Features.Email.Templates;
|
||||||
|
|
||||||
namespace api.Features.Email.Services;
|
namespace TrackQrApi.Features.Email.Services;
|
||||||
|
|
||||||
public class SmtpEmailService : IEmailService
|
public class SmtpEmailService : IEmailService
|
||||||
{
|
{
|
||||||
private readonly EmailSettings _settings;
|
|
||||||
private readonly ILogger<SmtpEmailService> _logger;
|
private readonly ILogger<SmtpEmailService> _logger;
|
||||||
|
private readonly EmailSettings _settings;
|
||||||
|
|
||||||
public SmtpEmailService(IOptions<EmailSettings> settings, ILogger<SmtpEmailService> logger)
|
public SmtpEmailService(IOptions<EmailSettings> settings, ILogger<SmtpEmailService> logger)
|
||||||
{
|
{
|
||||||
@@ -25,7 +25,8 @@ public class SmtpEmailService : IEmailService
|
|||||||
_logger.LogInformation("Password reset email sent to {Email}", toEmail);
|
_logger.LogInformation("Password reset email sent to {Email}", toEmail);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task SendEmailVerificationAsync(string toEmail, string verificationToken, CancellationToken ct = default)
|
public async Task SendEmailVerificationAsync(string toEmail, string verificationToken,
|
||||||
|
CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var verifyUrl = $"{_settings.BaseUrl}/verify-email?token={Uri.EscapeDataString(verificationToken)}";
|
var verifyUrl = $"{_settings.BaseUrl}/verify-email?token={Uri.EscapeDataString(verificationToken)}";
|
||||||
var (subject, htmlBody, textBody) = EmailTemplates.EmailVerification(verifyUrl);
|
var (subject, htmlBody, textBody) = EmailTemplates.EmailVerification(verifyUrl);
|
||||||
@@ -43,7 +44,8 @@ public class SmtpEmailService : IEmailService
|
|||||||
_logger.LogInformation("Welcome email sent to {Email}", toEmail);
|
_logger.LogInformation("Welcome email sent to {Email}", toEmail);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task SendEmailAsync(string toEmail, string subject, string htmlBody, string textBody, CancellationToken ct)
|
private async Task SendEmailAsync(string toEmail, string subject, string htmlBody, string textBody,
|
||||||
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (_settings.Smtp == null)
|
if (_settings.Smtp == null)
|
||||||
{
|
{
|
||||||
@@ -76,9 +78,7 @@ public class SmtpEmailService : IEmailService
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(_settings.Smtp.Username))
|
if (!string.IsNullOrEmpty(_settings.Smtp.Username))
|
||||||
{
|
|
||||||
client.Credentials = new NetworkCredential(_settings.Smtp.Username, _settings.Smtp.Password);
|
client.Credentials = new NetworkCredential(_settings.Smtp.Username, _settings.Smtp.Password);
|
||||||
}
|
|
||||||
|
|
||||||
await client.SendMailAsync(message, ct);
|
await client.SendMailAsync(message, ct);
|
||||||
_logger.LogDebug("Email sent successfully to {Email}", toEmail);
|
_logger.LogDebug("Email sent successfully to {Email}", toEmail);
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
namespace api.Features.Email.Templates;
|
namespace TrackQrApi.Features.Email.Templates;
|
||||||
|
|
||||||
public static class EmailTemplates
|
public static class EmailTemplates
|
||||||
{
|
{
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
using api.Data;
|
|
||||||
using api.Models;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TrackQrApi.Data;
|
||||||
|
using TrackQrApi.Models;
|
||||||
|
|
||||||
namespace api.Features.Events.Services;
|
namespace TrackQrApi.Features.Events.Services;
|
||||||
|
|
||||||
public interface IEventTrackingService
|
public interface IEventTrackingService
|
||||||
{
|
{
|
||||||
@@ -13,7 +13,10 @@ public interface IEventTrackingService
|
|||||||
Task TrackScanAsync(Guid workspaceId, Guid shortLinkId, Guid qrCodeId, HttpContext context);
|
Task TrackScanAsync(Guid workspaceId, Guid shortLinkId, Guid qrCodeId, HttpContext context);
|
||||||
}
|
}
|
||||||
|
|
||||||
public class EventTrackingService(IServiceScopeFactory scopeFactory, IGeoIpService geoIpService, ILogger<EventTrackingService> logger)
|
public class EventTrackingService(
|
||||||
|
IServiceScopeFactory scopeFactory,
|
||||||
|
IGeoIpService geoIpService,
|
||||||
|
ILogger<EventTrackingService> logger)
|
||||||
: IEventTrackingService
|
: IEventTrackingService
|
||||||
{
|
{
|
||||||
// Dedupe window - same visitor clicking same link within this window counts as one
|
// Dedupe window - same visitor clicking same link within this window counts as one
|
||||||
@@ -21,12 +24,14 @@ public class EventTrackingService(IServiceScopeFactory scopeFactory, IGeoIpServi
|
|||||||
|
|
||||||
public Task TrackClickAsync(Guid workspaceId, Guid shortLinkId, HttpContext context)
|
public Task TrackClickAsync(Guid workspaceId, Guid shortLinkId, HttpContext context)
|
||||||
{
|
{
|
||||||
// Fire and forget - don't block the redirect
|
// Extract request data before the HttpContext is disposed
|
||||||
|
var requestData = CaptureRequestData(context);
|
||||||
|
|
||||||
_ = Task.Run(async () =>
|
_ = Task.Run(async () =>
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await TrackEventInternalAsync(workspaceId, shortLinkId, null, EventType.Click, context);
|
await TrackEventInternalAsync(workspaceId, shortLinkId, null, EventType.Click, requestData);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -39,12 +44,13 @@ public class EventTrackingService(IServiceScopeFactory scopeFactory, IGeoIpServi
|
|||||||
|
|
||||||
public Task TrackScanAsync(Guid workspaceId, Guid shortLinkId, Guid qrCodeId, HttpContext context)
|
public Task TrackScanAsync(Guid workspaceId, Guid shortLinkId, Guid qrCodeId, HttpContext context)
|
||||||
{
|
{
|
||||||
// Fire and forget - don't block the redirect
|
var requestData = CaptureRequestData(context);
|
||||||
|
|
||||||
_ = Task.Run(async () =>
|
_ = Task.Run(async () =>
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await TrackEventInternalAsync(workspaceId, shortLinkId, qrCodeId, EventType.Scan, context);
|
await TrackEventInternalAsync(workspaceId, shortLinkId, qrCodeId, EventType.Scan, requestData);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -55,20 +61,25 @@ public class EventTrackingService(IServiceScopeFactory scopeFactory, IGeoIpServi
|
|||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static RequestData CaptureRequestData(HttpContext context) => new(
|
||||||
|
IpAddress: GetClientIpAddress(context),
|
||||||
|
UserAgent: context.Request.Headers.UserAgent.ToString(),
|
||||||
|
Referrer: context.Request.Headers.Referer.ToString()
|
||||||
|
);
|
||||||
|
|
||||||
private async Task TrackEventInternalAsync(
|
private async Task TrackEventInternalAsync(
|
||||||
Guid workspaceId,
|
Guid workspaceId,
|
||||||
Guid shortLinkId,
|
Guid shortLinkId,
|
||||||
Guid? qrCodeId,
|
Guid? qrCodeId,
|
||||||
EventType eventType,
|
EventType eventType,
|
||||||
HttpContext context)
|
RequestData requestData)
|
||||||
{
|
{
|
||||||
// Create a new scope for database access (since we're in a background task)
|
|
||||||
using var scope = scopeFactory.CreateScope();
|
using var scope = scopeFactory.CreateScope();
|
||||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
|
||||||
var ipAddress = GetClientIpAddress(context);
|
var ipAddress = requestData.IpAddress;
|
||||||
var userAgent = context.Request.Headers.UserAgent.ToString();
|
var userAgent = requestData.UserAgent;
|
||||||
var referrer = context.Request.Headers.Referer.ToString();
|
var referrer = requestData.Referrer;
|
||||||
|
|
||||||
var ipHash = HashIpAddress(ipAddress);
|
var ipHash = HashIpAddress(ipAddress);
|
||||||
var deviceType = ParseDeviceType(userAgent);
|
var deviceType = ParseDeviceType(userAgent);
|
||||||
@@ -112,10 +123,8 @@ public class EventTrackingService(IServiceScopeFactory scopeFactory, IGeoIpServi
|
|||||||
// Check for forwarded headers (when behind a proxy/load balancer)
|
// Check for forwarded headers (when behind a proxy/load balancer)
|
||||||
var forwardedFor = context.Request.Headers["X-Forwarded-For"].FirstOrDefault();
|
var forwardedFor = context.Request.Headers["X-Forwarded-For"].FirstOrDefault();
|
||||||
if (!string.IsNullOrEmpty(forwardedFor))
|
if (!string.IsNullOrEmpty(forwardedFor))
|
||||||
{
|
|
||||||
// Take the first IP in the chain (client IP)
|
// Take the first IP in the chain (client IP)
|
||||||
return forwardedFor.Split(',')[0].Trim();
|
return forwardedFor.Split(',')[0].Trim();
|
||||||
}
|
|
||||||
|
|
||||||
return context.Connection.RemoteIpAddress?.ToString() ?? "unknown";
|
return context.Connection.RemoteIpAddress?.ToString() ?? "unknown";
|
||||||
}
|
}
|
||||||
@@ -164,4 +173,6 @@ public class EventTrackingService(IServiceScopeFactory scopeFactory, IGeoIpServi
|
|||||||
|
|
||||||
return value.Length <= maxLength ? value : value[..maxLength];
|
return value.Length <= maxLength ? value : value[..maxLength];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sealed record RequestData(string IpAddress, string UserAgent, string Referrer);
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
using System.Net;
|
using System.Net;
|
||||||
using MaxMind.GeoIP2;
|
using MaxMind.GeoIP2;
|
||||||
|
|
||||||
namespace api.Features.Events.Services;
|
namespace TrackQrApi.Features.Events.Services;
|
||||||
|
|
||||||
public interface IGeoIpService
|
public interface IGeoIpService
|
||||||
{
|
{
|
||||||
@@ -10,8 +10,8 @@ public interface IGeoIpService
|
|||||||
|
|
||||||
public class GeoIpService : IGeoIpService, IDisposable
|
public class GeoIpService : IGeoIpService, IDisposable
|
||||||
{
|
{
|
||||||
private readonly DatabaseReader? _reader;
|
|
||||||
private readonly ILogger<GeoIpService> _logger;
|
private readonly ILogger<GeoIpService> _logger;
|
||||||
|
private readonly DatabaseReader? _reader;
|
||||||
|
|
||||||
public GeoIpService(IConfiguration configuration, ILogger<GeoIpService> logger)
|
public GeoIpService(IConfiguration configuration, ILogger<GeoIpService> logger)
|
||||||
{
|
{
|
||||||
@@ -19,7 +19,6 @@ public class GeoIpService : IGeoIpService, IDisposable
|
|||||||
var dbPath = configuration["GeoIP:DatabasePath"];
|
var dbPath = configuration["GeoIP:DatabasePath"];
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(dbPath) && File.Exists(dbPath))
|
if (!string.IsNullOrEmpty(dbPath) && File.Exists(dbPath))
|
||||||
{
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
_reader = new DatabaseReader(dbPath);
|
_reader = new DatabaseReader(dbPath);
|
||||||
@@ -29,11 +28,13 @@ public class GeoIpService : IGeoIpService, IDisposable
|
|||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "Failed to load GeoIP database from {Path}", dbPath);
|
_logger.LogWarning(ex, "Failed to load GeoIP database from {Path}", dbPath);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
else
|
else
|
||||||
{
|
|
||||||
_logger.LogInformation("GeoIP database not configured or not found. Country detection disabled.");
|
_logger.LogInformation("GeoIP database not configured or not found. Country detection disabled.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_reader?.Dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
public string? GetCountryCode(string ipAddress)
|
public string? GetCountryCode(string ipAddress)
|
||||||
@@ -44,20 +45,11 @@ public class GeoIpService : IGeoIpService, IDisposable
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Handle localhost and private IPs
|
// Handle localhost and private IPs
|
||||||
if (ipAddress == "127.0.0.1" || ipAddress == "::1" || IsPrivateIp(ipAddress))
|
if (ipAddress == "127.0.0.1" || ipAddress == "::1" || IsPrivateIp(ipAddress)) return null;
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!IPAddress.TryParse(ipAddress, out var ip))
|
if (!IPAddress.TryParse(ipAddress, out var ip)) return null;
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_reader.TryCountry(ip, out var response))
|
if (_reader.TryCountry(ip, out var response)) return response?.Country?.IsoCode;
|
||||||
{
|
|
||||||
return response?.Country?.IsoCode;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -87,9 +79,4 @@ public class GeoIpService : IGeoIpService, IDisposable
|
|||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose()
|
|
||||||
{
|
|
||||||
_reader?.Dispose();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
namespace api.Features.Links.Endpoints;
|
namespace TrackQrApi.Features.Links.Common;
|
||||||
|
|
||||||
public class LinkDto
|
public class LinkDto
|
||||||
{
|
{
|
||||||
public required Guid Id { get; set; }
|
public required Guid Id { get; set; }
|
||||||
public required string Slug { get; set; }
|
public required string Slug { get; set; }
|
||||||
public required string DestinationUrl { get; set; }
|
public required string DestinationUrl { get; set; }
|
||||||
public required string? Title { get; set; }
|
public required string? Title { get; set; }
|
||||||
public required string Status { get; set; }
|
public required string Status { get; set; }
|
||||||
public int ClickCount { get; set; }
|
public int ClickCount { get; set; }
|
||||||
public DateTimeOffset CreatedAt { get; set; }
|
public DateTimeOffset CreatedAt { get; set; }
|
||||||
};
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
namespace api.Features.Links.Common;
|
namespace TrackQrApi.Features.Links.Common;
|
||||||
|
|
||||||
public record LinkResponse(
|
public record LinkResponse(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
|
|
||||||
namespace api.Features.Links.Common;
|
namespace TrackQrApi.Features.Links.Common;
|
||||||
|
|
||||||
public static class SlugGenerator
|
public static class SlugGenerator
|
||||||
{
|
{
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using api.Data;
|
|
||||||
using api.Features.Auth.Common;
|
|
||||||
using api.Features.Links.Common;
|
|
||||||
using api.Models;
|
|
||||||
using FastEndpoints;
|
using FastEndpoints;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TrackQrApi.Data;
|
||||||
|
using TrackQrApi.Features.Auth.Common;
|
||||||
|
using TrackQrApi.Features.Links.Common;
|
||||||
|
using TrackQrApi.Models;
|
||||||
|
|
||||||
namespace api.Features.Links.Endpoints;
|
namespace TrackQrApi.Features.Links.Endpoints;
|
||||||
|
|
||||||
public class BulkCreateLinksRequest
|
public class BulkCreateLinksRequest
|
||||||
{
|
{
|
||||||
@@ -59,7 +59,8 @@ public class BulkCreateLinksEndpoint(AppDbContext db)
|
|||||||
// Limit bulk creation to 100 links at a time
|
// Limit bulk creation to 100 links at a time
|
||||||
if (req.Links.Count > 100)
|
if (req.Links.Count > 100)
|
||||||
{
|
{
|
||||||
await HttpContext.Response.SendAsync(new MessageResponse("Maximum 100 links per request"), 400, cancellation: ct);
|
await HttpContext.Response.SendAsync(new MessageResponse("Maximum 100 links per request"), 400,
|
||||||
|
cancellation: ct);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,7 +71,7 @@ public class BulkCreateLinksEndpoint(AppDbContext db)
|
|||||||
var currentLinkCount = await db.ShortLinks.CountAsync(l => l.WorkspaceId == req.WorkspaceId, ct);
|
var currentLinkCount = await db.ShortLinks.CountAsync(l => l.WorkspaceId == req.WorkspaceId, ct);
|
||||||
var linkLimit = GetPlanLinkLimit(workspace.Plan);
|
var linkLimit = GetPlanLinkLimit(workspace.Plan);
|
||||||
|
|
||||||
for (int i = 0; i < req.Links.Count; i++)
|
for (var i = 0; i < req.Links.Count; i++)
|
||||||
{
|
{
|
||||||
var item = req.Links[i];
|
var item = req.Links[i];
|
||||||
|
|
||||||
@@ -130,7 +131,7 @@ public class BulkCreateLinksEndpoint(AppDbContext db)
|
|||||||
Title = item.Title,
|
Title = item.Title,
|
||||||
Status = ShortLinkStatus.Active,
|
Status = ShortLinkStatus.Active,
|
||||||
CreatedAt = DateTime.UtcNow,
|
CreatedAt = DateTime.UtcNow,
|
||||||
UpdatedAt = DateTime.UtcNow,
|
UpdatedAt = DateTime.UtcNow
|
||||||
};
|
};
|
||||||
|
|
||||||
db.ShortLinks.Add(link);
|
db.ShortLinks.Add(link);
|
||||||
@@ -143,7 +144,7 @@ public class BulkCreateLinksEndpoint(AppDbContext db)
|
|||||||
Title = link?.Title,
|
Title = link?.Title,
|
||||||
Status = link.Status.ToString(),
|
Status = link.Status.ToString(),
|
||||||
ClickCount = 0,
|
ClickCount = 0,
|
||||||
CreatedAt = link.CreatedAt,
|
CreatedAt = link.CreatedAt
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using api.Data;
|
|
||||||
using api.Features.Auth.Common;
|
|
||||||
using api.Features.Links.Common;
|
|
||||||
using api.Features.Plans.Services;
|
|
||||||
using api.Models;
|
|
||||||
using FastEndpoints;
|
using FastEndpoints;
|
||||||
using FluentValidation;
|
using FluentValidation;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TrackQrApi.Data;
|
||||||
|
using TrackQrApi.Features.Auth.Common;
|
||||||
|
using TrackQrApi.Features.Links.Common;
|
||||||
|
using TrackQrApi.Features.Plans.Services;
|
||||||
|
using TrackQrApi.Models;
|
||||||
|
|
||||||
namespace api.Features.Links.Endpoints;
|
namespace TrackQrApi.Features.Links.Endpoints;
|
||||||
|
|
||||||
public class CreateLinkRequest
|
public class CreateLinkRequest
|
||||||
{
|
{
|
||||||
@@ -30,7 +30,8 @@ public class CreateLinkValidator : Validator<CreateLinkRequest>
|
|||||||
|
|
||||||
RuleFor(x => x.Slug)
|
RuleFor(x => x.Slug)
|
||||||
.MaximumLength(50).WithMessage("Slug must not exceed 50 characters")
|
.MaximumLength(50).WithMessage("Slug must not exceed 50 characters")
|
||||||
.Matches(@"^[a-zA-Z0-9_-]*$").WithMessage("Slug can only contain letters, numbers, hyphens, and underscores")
|
.Matches(@"^[a-zA-Z0-9_-]*$")
|
||||||
|
.WithMessage("Slug can only contain letters, numbers, hyphens, and underscores")
|
||||||
.When(x => !string.IsNullOrEmpty(x.Slug));
|
.When(x => !string.IsNullOrEmpty(x.Slug));
|
||||||
|
|
||||||
RuleFor(x => x.Title)
|
RuleFor(x => x.Title)
|
||||||
@@ -107,7 +108,8 @@ public class CreateLinkEndpoint(AppDbContext db, IPlanLimitsService planLimits)
|
|||||||
|
|
||||||
if (slugExists)
|
if (slugExists)
|
||||||
{
|
{
|
||||||
await HttpContext.Response.SendAsync(new MessageResponse("Slug is already taken"), 409, cancellation: ct);
|
await HttpContext.Response.SendAsync(new MessageResponse("Slug is already taken"), 409,
|
||||||
|
cancellation: ct);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using api.Data;
|
|
||||||
using api.Features.Auth.Common;
|
|
||||||
using FastEndpoints;
|
using FastEndpoints;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TrackQrApi.Data;
|
||||||
|
using TrackQrApi.Features.Auth.Common;
|
||||||
|
|
||||||
namespace api.Features.Links.Endpoints;
|
namespace TrackQrApi.Features.Links.Endpoints;
|
||||||
|
|
||||||
public class DeleteLinkRequest
|
public class DeleteLinkRequest
|
||||||
{
|
{
|
||||||
@@ -42,6 +42,6 @@ public class DeleteLinkEndpoint(AppDbContext db)
|
|||||||
link.DeletedAt = DateTime.UtcNow;
|
link.DeletedAt = DateTime.UtcNow;
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
await HttpContext.Response.SendAsync(new MessageResponse("Link deleted"), 200, cancellation: ct);
|
await HttpContext.Response.SendAsync(new MessageResponse("Link deleted"), cancellation: ct);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using api.Data;
|
|
||||||
using api.Features.Auth.Common;
|
|
||||||
using api.Features.Links.Common;
|
|
||||||
using FastEndpoints;
|
using FastEndpoints;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TrackQrApi.Data;
|
||||||
|
using TrackQrApi.Features.Auth.Common;
|
||||||
|
using TrackQrApi.Features.Links.Common;
|
||||||
|
|
||||||
namespace api.Features.Links.Endpoints;
|
namespace TrackQrApi.Features.Links.Endpoints;
|
||||||
|
|
||||||
public class GetLinkRequest
|
public class GetLinkRequest
|
||||||
{
|
{
|
||||||
@@ -26,7 +26,8 @@ public class GetLinkEndpoint(AppDbContext db)
|
|||||||
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||||
|
|
||||||
var link = await db.ShortLinks
|
var link = await db.ShortLinks
|
||||||
.Where(l => l.Id == req.Id && l.WorkspaceId == req.WorkspaceId && l.Workspace.OwnerUserId == userId && l.DeletedAt == null)
|
.Where(l => l.Id == req.Id && l.WorkspaceId == req.WorkspaceId && l.Workspace.OwnerUserId == userId &&
|
||||||
|
l.DeletedAt == null)
|
||||||
.Select(l => new LinkResponse(
|
.Select(l => new LinkResponse(
|
||||||
l.Id,
|
l.Id,
|
||||||
l.WorkspaceId,
|
l.WorkspaceId,
|
||||||
@@ -50,6 +51,6 @@ public class GetLinkEndpoint(AppDbContext db)
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await HttpContext.Response.SendAsync(link, 200, cancellation: ct);
|
await HttpContext.Response.SendAsync(link, cancellation: ct);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using api.Data;
|
|
||||||
using api.Features.Auth.Common;
|
|
||||||
using api.Features.Links.Common;
|
|
||||||
using FastEndpoints;
|
using FastEndpoints;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TrackQrApi.Data;
|
||||||
|
using TrackQrApi.Features.Auth.Common;
|
||||||
|
using TrackQrApi.Features.Links.Common;
|
||||||
|
using TrackQrApi.Models;
|
||||||
|
|
||||||
namespace api.Features.Links.Endpoints;
|
namespace TrackQrApi.Features.Links.Endpoints;
|
||||||
|
|
||||||
public class ListLinksRequest
|
public class ListLinksRequest
|
||||||
{
|
{
|
||||||
@@ -41,22 +42,14 @@ public class ListLinksEndpoint(AppDbContext db)
|
|||||||
.Where(l => l.WorkspaceId == req.WorkspaceId);
|
.Where(l => l.WorkspaceId == req.WorkspaceId);
|
||||||
|
|
||||||
// Filter by deleted status (exclude soft-deleted by default)
|
// Filter by deleted status (exclude soft-deleted by default)
|
||||||
if (!req.IncludeDeleted)
|
if (!req.IncludeDeleted) query = query.Where(l => l.DeletedAt == null);
|
||||||
{
|
|
||||||
query = query.Where(l => l.DeletedAt == null);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter by project if specified
|
// Filter by project if specified
|
||||||
if (req.ProjectId.HasValue)
|
if (req.ProjectId.HasValue) query = query.Where(l => l.ProjectId == req.ProjectId.Value);
|
||||||
{
|
|
||||||
query = query.Where(l => l.ProjectId == req.ProjectId.Value);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter by status if specified
|
// Filter by status if specified
|
||||||
if (!string.IsNullOrEmpty(req.Status) && Enum.TryParse<Models.ShortLinkStatus>(req.Status, true, out var status))
|
if (!string.IsNullOrEmpty(req.Status) && Enum.TryParse<ShortLinkStatus>(req.Status, true, out var status))
|
||||||
{
|
|
||||||
query = query.Where(l => l.Status == status);
|
query = query.Where(l => l.Status == status);
|
||||||
}
|
|
||||||
|
|
||||||
var links = await query
|
var links = await query
|
||||||
.OrderByDescending(l => l.CreatedAt)
|
.OrderByDescending(l => l.CreatedAt)
|
||||||
@@ -77,6 +70,6 @@ public class ListLinksEndpoint(AppDbContext db)
|
|||||||
))
|
))
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
|
|
||||||
await HttpContext.Response.SendAsync(new LinkListResponse(links), 200, cancellation: ct);
|
await HttpContext.Response.SendAsync(new LinkListResponse(links), cancellation: ct);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using api.Data;
|
|
||||||
using api.Features.Auth.Common;
|
|
||||||
using api.Features.Links.Common;
|
|
||||||
using FastEndpoints;
|
using FastEndpoints;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TrackQrApi.Data;
|
||||||
|
using TrackQrApi.Features.Auth.Common;
|
||||||
|
using TrackQrApi.Features.Links.Common;
|
||||||
|
|
||||||
namespace api.Features.Links.Endpoints;
|
namespace TrackQrApi.Features.Links.Endpoints;
|
||||||
|
|
||||||
public class RestoreLinkRequest
|
public class RestoreLinkRequest
|
||||||
{
|
{
|
||||||
@@ -60,6 +60,6 @@ public class RestoreLinkEndpoint(AppDbContext db)
|
|||||||
link.DeletedAt
|
link.DeletedAt
|
||||||
);
|
);
|
||||||
|
|
||||||
await HttpContext.Response.SendAsync(response, 200, cancellation: ct);
|
await HttpContext.Response.SendAsync(response, cancellation: ct);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using api.Data;
|
|
||||||
using api.Features.Auth.Common;
|
|
||||||
using api.Features.Links.Common;
|
|
||||||
using api.Models;
|
|
||||||
using FastEndpoints;
|
using FastEndpoints;
|
||||||
using FluentValidation;
|
using FluentValidation;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TrackQrApi.Data;
|
||||||
|
using TrackQrApi.Features.Auth.Common;
|
||||||
|
using TrackQrApi.Features.Links.Common;
|
||||||
|
using TrackQrApi.Models;
|
||||||
|
|
||||||
namespace api.Features.Links.Endpoints;
|
namespace TrackQrApi.Features.Links.Endpoints;
|
||||||
|
|
||||||
public class UpdateLinkRequest
|
public class UpdateLinkRequest
|
||||||
{
|
{
|
||||||
@@ -66,7 +66,8 @@ public class UpdateLinkEndpoint(AppDbContext db)
|
|||||||
|
|
||||||
var link = await db.ShortLinks
|
var link = await db.ShortLinks
|
||||||
.Include(l => l.Workspace)
|
.Include(l => l.Workspace)
|
||||||
.FirstOrDefaultAsync(l => l.Id == req.Id && l.WorkspaceId == req.WorkspaceId && l.Workspace.OwnerUserId == userId, ct);
|
.FirstOrDefaultAsync(
|
||||||
|
l => l.Id == req.Id && l.WorkspaceId == req.WorkspaceId && l.Workspace.OwnerUserId == userId, ct);
|
||||||
|
|
||||||
if (link is null)
|
if (link is null)
|
||||||
{
|
{
|
||||||
@@ -88,43 +89,22 @@ public class UpdateLinkEndpoint(AppDbContext db)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Update fields
|
// Update fields
|
||||||
if (!string.IsNullOrEmpty(req.DestinationUrl))
|
if (!string.IsNullOrEmpty(req.DestinationUrl)) link.DestinationUrl = req.DestinationUrl;
|
||||||
{
|
|
||||||
link.DestinationUrl = req.DestinationUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (req.Title != null)
|
if (req.Title != null) link.Title = req.Title;
|
||||||
{
|
|
||||||
link.Title = req.Title;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(req.Status) && Enum.TryParse<ShortLinkStatus>(req.Status, true, out var status))
|
if (!string.IsNullOrEmpty(req.Status) && Enum.TryParse<ShortLinkStatus>(req.Status, true, out var status))
|
||||||
{
|
|
||||||
link.Status = status;
|
link.Status = status;
|
||||||
}
|
|
||||||
|
|
||||||
if (req.ExpiresAt.HasValue)
|
if (req.ExpiresAt.HasValue) link.ExpiresAt = req.ExpiresAt.Value;
|
||||||
{
|
|
||||||
link.ExpiresAt = req.ExpiresAt.Value;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(req.Password))
|
if (!string.IsNullOrEmpty(req.Password))
|
||||||
{
|
|
||||||
link.PasswordHash = BCrypt.Net.BCrypt.HashPassword(req.Password);
|
link.PasswordHash = BCrypt.Net.BCrypt.HashPassword(req.Password);
|
||||||
}
|
else if (req.RemovePassword == true) link.PasswordHash = null;
|
||||||
else if (req.RemovePassword == true)
|
|
||||||
{
|
|
||||||
link.PasswordHash = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (req.ProjectId.HasValue)
|
if (req.ProjectId.HasValue)
|
||||||
{
|
|
||||||
link.ProjectId = req.ProjectId.Value;
|
link.ProjectId = req.ProjectId.Value;
|
||||||
}
|
else if (req.RemoveProject == true) link.ProjectId = null;
|
||||||
else if (req.RemoveProject == true)
|
|
||||||
{
|
|
||||||
link.ProjectId = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
link.UpdatedAt = DateTime.UtcNow;
|
link.UpdatedAt = DateTime.UtcNow;
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
@@ -144,6 +124,6 @@ public class UpdateLinkEndpoint(AppDbContext db)
|
|||||||
link.UpdatedAt
|
link.UpdatedAt
|
||||||
);
|
);
|
||||||
|
|
||||||
await HttpContext.Response.SendAsync(response, 200, cancellation: ct);
|
await HttpContext.Response.SendAsync(response, cancellation: ct);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using FastEndpoints;
|
||||||
|
using TrackQrApi.Features.Plans.Services;
|
||||||
|
|
||||||
|
namespace TrackQrApi.Features.Plans.Endpoints;
|
||||||
|
|
||||||
|
public class GetUsageRequest
|
||||||
|
{
|
||||||
|
public Guid? WorkspaceId { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public record UsageResponse(
|
||||||
|
int Workspaces,
|
||||||
|
int Links,
|
||||||
|
int QRCodes,
|
||||||
|
int Domains,
|
||||||
|
int EventsThisMonth,
|
||||||
|
string Plan,
|
||||||
|
LimitsResponse Limits
|
||||||
|
);
|
||||||
|
|
||||||
|
public record LimitsResponse(
|
||||||
|
int MaxWorkspaces,
|
||||||
|
int MaxLinks,
|
||||||
|
int MaxQRCodes,
|
||||||
|
int MaxDomains,
|
||||||
|
int MaxEventsPerMonth,
|
||||||
|
bool HasCustomDomains,
|
||||||
|
bool HasPasswordProtection
|
||||||
|
);
|
||||||
|
|
||||||
|
public class GetUsageEndpoint(IPlanLimitsService planLimits)
|
||||||
|
: Endpoint<GetUsageRequest, UsageResponse>
|
||||||
|
{
|
||||||
|
public override void Configure()
|
||||||
|
{
|
||||||
|
Get("/usage");
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task HandleAsync(GetUsageRequest req, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||||
|
|
||||||
|
if (req.WorkspaceId.HasValue)
|
||||||
|
{
|
||||||
|
var wsUsage = await planLimits.GetWorkspaceUsageAsync(req.WorkspaceId.Value, ct);
|
||||||
|
|
||||||
|
var response = new UsageResponse(
|
||||||
|
1,
|
||||||
|
wsUsage.Links,
|
||||||
|
wsUsage.QRCodes,
|
||||||
|
wsUsage.Domains,
|
||||||
|
wsUsage.EventsThisMonth,
|
||||||
|
wsUsage.Plan.ToString(),
|
||||||
|
new LimitsResponse(
|
||||||
|
wsUsage.Limits.MaxWorkspaces,
|
||||||
|
wsUsage.Limits.MaxLinksPerWorkspace,
|
||||||
|
wsUsage.Limits.MaxQRCodesPerWorkspace,
|
||||||
|
wsUsage.Limits.MaxDomainsPerWorkspace,
|
||||||
|
wsUsage.Limits.MaxEventsPerMonth,
|
||||||
|
wsUsage.Limits.HasCustomDomains,
|
||||||
|
wsUsage.Limits.HasPasswordProtection
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
await HttpContext.Response.SendAsync(response, cancellation: ct);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var usage = await planLimits.GetUsageAsync(userId, ct);
|
||||||
|
var limits = planLimits.GetLimits(usage.HighestPlan);
|
||||||
|
|
||||||
|
var response = new UsageResponse(
|
||||||
|
usage.TotalWorkspaces,
|
||||||
|
usage.TotalLinks,
|
||||||
|
usage.TotalQRCodes,
|
||||||
|
usage.TotalDomains,
|
||||||
|
usage.EventsThisMonth,
|
||||||
|
usage.HighestPlan.ToString(),
|
||||||
|
new LimitsResponse(
|
||||||
|
limits.MaxWorkspaces,
|
||||||
|
limits.MaxLinksPerWorkspace,
|
||||||
|
limits.MaxQRCodesPerWorkspace,
|
||||||
|
limits.MaxDomainsPerWorkspace,
|
||||||
|
limits.MaxEventsPerMonth,
|
||||||
|
limits.HasCustomDomains,
|
||||||
|
limits.HasPasswordProtection
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
await HttpContext.Response.SendAsync(response, cancellation: ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
using api.Data;
|
|
||||||
using api.Models;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TrackQrApi.Data;
|
||||||
|
using TrackQrApi.Models;
|
||||||
|
|
||||||
namespace api.Features.Plans.Services;
|
namespace TrackQrApi.Features.Plans.Services;
|
||||||
|
|
||||||
public interface IPlanLimitsService
|
public interface IPlanLimitsService
|
||||||
{
|
{
|
||||||
@@ -51,38 +51,41 @@ public class PlanLimitsService(IServiceScopeFactory scopeFactory) : IPlanLimitsS
|
|||||||
private static readonly Dictionary<WorkspacePlan, PlanLimits> PlanConfigs = new()
|
private static readonly Dictionary<WorkspacePlan, PlanLimits> PlanConfigs = new()
|
||||||
{
|
{
|
||||||
[WorkspacePlan.Free] = new PlanLimits(
|
[WorkspacePlan.Free] = new PlanLimits(
|
||||||
MaxWorkspaces: 1,
|
1,
|
||||||
MaxLinksPerWorkspace: 50,
|
50,
|
||||||
MaxQRCodesPerWorkspace: 25,
|
25,
|
||||||
MaxDomainsPerWorkspace: 0,
|
0,
|
||||||
MaxEventsPerMonth: 10_000,
|
10_000,
|
||||||
HasCustomDomains: false,
|
false,
|
||||||
HasPasswordProtection: false,
|
false,
|
||||||
HasAnalytics: true
|
true
|
||||||
),
|
),
|
||||||
[WorkspacePlan.Pro] = new PlanLimits(
|
[WorkspacePlan.Pro] = new PlanLimits(
|
||||||
MaxWorkspaces: 5,
|
5,
|
||||||
MaxLinksPerWorkspace: 5_000,
|
5_000,
|
||||||
MaxQRCodesPerWorkspace: 1_000,
|
1_000,
|
||||||
MaxDomainsPerWorkspace: 3,
|
3,
|
||||||
MaxEventsPerMonth: 100_000,
|
100_000,
|
||||||
HasCustomDomains: true,
|
true,
|
||||||
HasPasswordProtection: true,
|
true,
|
||||||
HasAnalytics: true
|
true
|
||||||
),
|
),
|
||||||
[WorkspacePlan.Business] = new PlanLimits(
|
[WorkspacePlan.Business] = new PlanLimits(
|
||||||
MaxWorkspaces: int.MaxValue,
|
int.MaxValue,
|
||||||
MaxLinksPerWorkspace: int.MaxValue,
|
int.MaxValue,
|
||||||
MaxQRCodesPerWorkspace: int.MaxValue,
|
int.MaxValue,
|
||||||
MaxDomainsPerWorkspace: int.MaxValue,
|
int.MaxValue,
|
||||||
MaxEventsPerMonth: int.MaxValue,
|
int.MaxValue,
|
||||||
HasCustomDomains: true,
|
true,
|
||||||
HasPasswordProtection: true,
|
true,
|
||||||
HasAnalytics: true
|
true
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
public PlanLimits GetLimits(WorkspacePlan plan) => PlanConfigs[plan];
|
public PlanLimits GetLimits(WorkspacePlan plan)
|
||||||
|
{
|
||||||
|
return PlanConfigs[plan];
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<UsageStats> GetUsageAsync(Guid userId, CancellationToken ct = default)
|
public async Task<UsageStats> GetUsageAsync(Guid userId, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
@@ -114,12 +117,12 @@ public class PlanLimitsService(IServiceScopeFactory scopeFactory) : IPlanLimitsS
|
|||||||
: WorkspacePlan.Free;
|
: WorkspacePlan.Free;
|
||||||
|
|
||||||
return new UsageStats(
|
return new UsageStats(
|
||||||
TotalWorkspaces: workspaces.Count,
|
workspaces.Count,
|
||||||
TotalLinks: totalLinks,
|
totalLinks,
|
||||||
TotalQRCodes: totalQRCodes,
|
totalQRCodes,
|
||||||
TotalDomains: totalDomains,
|
totalDomains,
|
||||||
EventsThisMonth: eventsThisMonth,
|
eventsThisMonth,
|
||||||
HighestPlan: highestPlan
|
highestPlan
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,13 +150,13 @@ public class PlanLimitsService(IServiceScopeFactory scopeFactory) : IPlanLimitsS
|
|||||||
var limits = GetLimits(workspace.Plan);
|
var limits = GetLimits(workspace.Plan);
|
||||||
|
|
||||||
return new WorkspaceUsageStats(
|
return new WorkspaceUsageStats(
|
||||||
WorkspaceId: workspaceId,
|
workspaceId,
|
||||||
Plan: workspace.Plan,
|
workspace.Plan,
|
||||||
Links: links,
|
links,
|
||||||
QRCodes: qrCodes,
|
qrCodes,
|
||||||
Domains: domains,
|
domains,
|
||||||
EventsThisMonth: eventsThisMonth,
|
eventsThisMonth,
|
||||||
Limits: limits
|
limits
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
namespace api.Features.Projects.Common;
|
namespace TrackQrApi.Features.Projects.Common;
|
||||||
|
|
||||||
public record ProjectResponse(
|
public record ProjectResponse(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using api.Data;
|
|
||||||
using api.Features.Auth.Common;
|
|
||||||
using api.Features.Projects.Common;
|
|
||||||
using api.Models;
|
|
||||||
using FastEndpoints;
|
using FastEndpoints;
|
||||||
using FluentValidation;
|
using FluentValidation;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TrackQrApi.Data;
|
||||||
|
using TrackQrApi.Features.Auth.Common;
|
||||||
|
using TrackQrApi.Features.Projects.Common;
|
||||||
|
using TrackQrApi.Models;
|
||||||
|
|
||||||
namespace api.Features.Projects.Endpoints;
|
namespace TrackQrApi.Features.Projects.Endpoints;
|
||||||
|
|
||||||
public class CreateProjectRequest
|
public class CreateProjectRequest
|
||||||
{
|
{
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using api.Data;
|
|
||||||
using api.Features.Auth.Common;
|
|
||||||
using FastEndpoints;
|
using FastEndpoints;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TrackQrApi.Data;
|
||||||
|
using TrackQrApi.Features.Auth.Common;
|
||||||
|
|
||||||
namespace api.Features.Projects.Endpoints;
|
namespace TrackQrApi.Features.Projects.Endpoints;
|
||||||
|
|
||||||
public class DeleteProjectRequest
|
public class DeleteProjectRequest
|
||||||
{
|
{
|
||||||
@@ -26,7 +26,8 @@ public class DeleteProjectEndpoint(AppDbContext db)
|
|||||||
|
|
||||||
var project = await db.Projects
|
var project = await db.Projects
|
||||||
.Include(p => p.Workspace)
|
.Include(p => p.Workspace)
|
||||||
.FirstOrDefaultAsync(p => p.Id == req.Id && p.WorkspaceId == req.WorkspaceId && p.Workspace.OwnerUserId == userId, ct);
|
.FirstOrDefaultAsync(
|
||||||
|
p => p.Id == req.Id && p.WorkspaceId == req.WorkspaceId && p.Workspace.OwnerUserId == userId, ct);
|
||||||
|
|
||||||
if (project is null)
|
if (project is null)
|
||||||
{
|
{
|
||||||
@@ -37,6 +38,6 @@ public class DeleteProjectEndpoint(AppDbContext db)
|
|||||||
db.Projects.Remove(project);
|
db.Projects.Remove(project);
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
await HttpContext.Response.SendAsync(new MessageResponse("Project deleted"), 200, cancellation: ct);
|
await HttpContext.Response.SendAsync(new MessageResponse("Project deleted"), cancellation: ct);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using api.Data;
|
|
||||||
using api.Features.Auth.Common;
|
|
||||||
using api.Features.Projects.Common;
|
|
||||||
using FastEndpoints;
|
using FastEndpoints;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TrackQrApi.Data;
|
||||||
|
using TrackQrApi.Features.Auth.Common;
|
||||||
|
using TrackQrApi.Features.Projects.Common;
|
||||||
|
|
||||||
namespace api.Features.Projects.Endpoints;
|
namespace TrackQrApi.Features.Projects.Endpoints;
|
||||||
|
|
||||||
public class GetProjectRequest
|
public class GetProjectRequest
|
||||||
{
|
{
|
||||||
@@ -44,6 +44,6 @@ public class GetProjectEndpoint(AppDbContext db)
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await HttpContext.Response.SendAsync(project, 200, cancellation: ct);
|
await HttpContext.Response.SendAsync(project, cancellation: ct);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using api.Data;
|
|
||||||
using api.Features.Auth.Common;
|
|
||||||
using api.Features.Projects.Common;
|
|
||||||
using FastEndpoints;
|
using FastEndpoints;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TrackQrApi.Data;
|
||||||
|
using TrackQrApi.Features.Auth.Common;
|
||||||
|
using TrackQrApi.Features.Projects.Common;
|
||||||
|
|
||||||
namespace api.Features.Projects.Endpoints;
|
namespace TrackQrApi.Features.Projects.Endpoints;
|
||||||
|
|
||||||
public class ListProjectsRequest
|
public class ListProjectsRequest
|
||||||
{
|
{
|
||||||
@@ -48,6 +48,6 @@ public class ListProjectsEndpoint(AppDbContext db)
|
|||||||
))
|
))
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
|
|
||||||
await HttpContext.Response.SendAsync(new ProjectListResponse(projects), 200, cancellation: ct);
|
await HttpContext.Response.SendAsync(new ProjectListResponse(projects), cancellation: ct);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using api.Data;
|
|
||||||
using api.Features.Auth.Common;
|
|
||||||
using api.Features.Projects.Common;
|
|
||||||
using FastEndpoints;
|
using FastEndpoints;
|
||||||
using FluentValidation;
|
using FluentValidation;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TrackQrApi.Data;
|
||||||
|
using TrackQrApi.Features.Auth.Common;
|
||||||
|
using TrackQrApi.Features.Projects.Common;
|
||||||
|
|
||||||
namespace api.Features.Projects.Endpoints;
|
namespace TrackQrApi.Features.Projects.Endpoints;
|
||||||
|
|
||||||
public class UpdateProjectRequest
|
public class UpdateProjectRequest
|
||||||
{
|
{
|
||||||
@@ -42,7 +42,8 @@ public class UpdateProjectEndpoint(AppDbContext db)
|
|||||||
.Include(p => p.Workspace)
|
.Include(p => p.Workspace)
|
||||||
.Include(p => p.ShortLinks)
|
.Include(p => p.ShortLinks)
|
||||||
.Include(p => p.QRCodeDesigns)
|
.Include(p => p.QRCodeDesigns)
|
||||||
.FirstOrDefaultAsync(p => p.Id == req.Id && p.WorkspaceId == req.WorkspaceId && p.Workspace.OwnerUserId == userId, ct);
|
.FirstOrDefaultAsync(
|
||||||
|
p => p.Id == req.Id && p.WorkspaceId == req.WorkspaceId && p.Workspace.OwnerUserId == userId, ct);
|
||||||
|
|
||||||
if (project is null)
|
if (project is null)
|
||||||
{
|
{
|
||||||
@@ -64,6 +65,6 @@ public class UpdateProjectEndpoint(AppDbContext db)
|
|||||||
project.CreatedAt
|
project.CreatedAt
|
||||||
);
|
);
|
||||||
|
|
||||||
await HttpContext.Response.SendAsync(response, 200, cancellation: ct);
|
await HttpContext.Response.SendAsync(response, cancellation: ct);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,7 @@
|
|||||||
using System.Text.Json.Serialization;
|
namespace TrackQrApi.Features.QRCodes.Common;
|
||||||
|
|
||||||
namespace api.Features.QRCodes.Common;
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// QR code style configuration stored as JSON
|
/// QR code style configuration stored as JSON
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class QRCodeStyle
|
public class QRCodeStyle
|
||||||
{
|
{
|
||||||
@@ -1,15 +1,15 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using api.Data;
|
|
||||||
using api.Features.Auth.Common;
|
|
||||||
using api.Features.Plans.Services;
|
|
||||||
using api.Features.QRCodes.Common;
|
|
||||||
using api.Models;
|
|
||||||
using FastEndpoints;
|
using FastEndpoints;
|
||||||
using FluentValidation;
|
using FluentValidation;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TrackQrApi.Data;
|
||||||
|
using TrackQrApi.Features.Auth.Common;
|
||||||
|
using TrackQrApi.Features.Plans.Services;
|
||||||
|
using TrackQrApi.Features.QRCodes.Common;
|
||||||
|
using TrackQrApi.Models;
|
||||||
|
|
||||||
namespace api.Features.QRCodes.Endpoints;
|
namespace TrackQrApi.Features.QRCodes.Endpoints;
|
||||||
|
|
||||||
public class CreateQRCodeRequest
|
public class CreateQRCodeRequest
|
||||||
{
|
{
|
||||||
@@ -73,9 +73,11 @@ public class CreateQRCodeEndpoint(AppDbContext db, IPlanLimitsService planLimits
|
|||||||
|
|
||||||
if (link is null)
|
if (link is null)
|
||||||
{
|
{
|
||||||
await HttpContext.Response.SendAsync(new MessageResponse("Short link not found"), 404, cancellation: ct);
|
await HttpContext.Response.SendAsync(new MessageResponse("Short link not found"), 404,
|
||||||
|
cancellation: ct);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
linkSlug = link.Slug;
|
linkSlug = link.Slug;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,7 +105,8 @@ public class CreateQRCodeEndpoint(AppDbContext db, IPlanLimitsService planLimits
|
|||||||
|
|
||||||
if (asset is null)
|
if (asset is null)
|
||||||
{
|
{
|
||||||
await HttpContext.Response.SendAsync(new MessageResponse("Logo asset not found"), 404, cancellation: ct);
|
await HttpContext.Response.SendAsync(new MessageResponse("Logo asset not found"), 404,
|
||||||
|
cancellation: ct);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using api.Data;
|
|
||||||
using api.Features.Auth.Common;
|
|
||||||
using FastEndpoints;
|
using FastEndpoints;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TrackQrApi.Data;
|
||||||
|
using TrackQrApi.Features.Auth.Common;
|
||||||
|
|
||||||
namespace api.Features.QRCodes.Endpoints;
|
namespace TrackQrApi.Features.QRCodes.Endpoints;
|
||||||
|
|
||||||
public class DeleteQRCodeRequest
|
public class DeleteQRCodeRequest
|
||||||
{
|
{
|
||||||
@@ -26,7 +26,8 @@ public class DeleteQRCodeEndpoint(AppDbContext db)
|
|||||||
|
|
||||||
var qrCode = await db.QrCodeDesigns
|
var qrCode = await db.QrCodeDesigns
|
||||||
.Include(q => q.Workspace)
|
.Include(q => q.Workspace)
|
||||||
.FirstOrDefaultAsync(q => q.Id == req.Id && q.WorkspaceId == req.WorkspaceId && q.Workspace.OwnerUserId == userId, ct);
|
.FirstOrDefaultAsync(
|
||||||
|
q => q.Id == req.Id && q.WorkspaceId == req.WorkspaceId && q.Workspace.OwnerUserId == userId, ct);
|
||||||
|
|
||||||
if (qrCode is null)
|
if (qrCode is null)
|
||||||
{
|
{
|
||||||
@@ -37,6 +38,6 @@ public class DeleteQRCodeEndpoint(AppDbContext db)
|
|||||||
db.QrCodeDesigns.Remove(qrCode);
|
db.QrCodeDesigns.Remove(qrCode);
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
await HttpContext.Response.SendAsync(new MessageResponse("QR code deleted"), 200, cancellation: ct);
|
await HttpContext.Response.SendAsync(new MessageResponse("QR code deleted"), cancellation: ct);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using api.Data;
|
|
||||||
using api.Features.Assets.Services;
|
|
||||||
using api.Features.Auth.Common;
|
|
||||||
using api.Features.QRCodes.Common;
|
|
||||||
using api.Features.QRCodes.Services;
|
|
||||||
using FastEndpoints;
|
using FastEndpoints;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TrackQrApi.Data;
|
||||||
|
using TrackQrApi.Features.Assets.Services;
|
||||||
|
using TrackQrApi.Features.Auth.Common;
|
||||||
|
using TrackQrApi.Features.QRCodes.Common;
|
||||||
|
using TrackQrApi.Features.QRCodes.Services;
|
||||||
|
|
||||||
namespace api.Features.QRCodes.Endpoints;
|
namespace TrackQrApi.Features.QRCodes.Endpoints;
|
||||||
|
|
||||||
public class ExportQRCodeRequest
|
public class ExportQRCodeRequest
|
||||||
{
|
{
|
||||||
@@ -18,7 +18,10 @@ public class ExportQRCodeRequest
|
|||||||
public int? Size { get; set; }
|
public int? Size { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class ExportQRCodeEndpoint(AppDbContext db, IQrCodeGeneratorService qrGenerator, IAssetStorageService assetStorage)
|
public class ExportQRCodeEndpoint(
|
||||||
|
AppDbContext db,
|
||||||
|
IQrCodeGeneratorService qrGenerator,
|
||||||
|
IAssetStorageService assetStorage)
|
||||||
: Endpoint<ExportQRCodeRequest>
|
: Endpoint<ExportQRCodeRequest>
|
||||||
{
|
{
|
||||||
public override void Configure()
|
public override void Configure()
|
||||||
@@ -44,7 +47,8 @@ public class ExportQRCodeEndpoint(AppDbContext db, IQrCodeGeneratorService qrGen
|
|||||||
|
|
||||||
if (qrCode.ShortLink is null)
|
if (qrCode.ShortLink is null)
|
||||||
{
|
{
|
||||||
await HttpContext.Response.SendAsync(new MessageResponse("QR code has no associated link"), 400, cancellation: ct);
|
await HttpContext.Response.SendAsync(new MessageResponse("QR code has no associated link"), 400,
|
||||||
|
cancellation: ct);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,10 +67,7 @@ public class ExportQRCodeEndpoint(AppDbContext db, IQrCodeGeneratorService qrGen
|
|||||||
if (qrCode.LogoAsset != null)
|
if (qrCode.LogoAsset != null)
|
||||||
{
|
{
|
||||||
var logoResult = await assetStorage.GetAsync(qrCode.LogoAsset.StorageKey);
|
var logoResult = await assetStorage.GetAsync(qrCode.LogoAsset.StorageKey);
|
||||||
if (logoResult.HasValue)
|
if (logoResult.HasValue) logoStream = logoResult.Value.Stream;
|
||||||
{
|
|
||||||
logoStream = logoResult.Value.Stream;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using api.Data;
|
|
||||||
using api.Features.Auth.Common;
|
|
||||||
using api.Models;
|
|
||||||
using FastEndpoints;
|
using FastEndpoints;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TrackQrApi.Data;
|
||||||
|
using TrackQrApi.Features.Auth.Common;
|
||||||
|
using TrackQrApi.Models;
|
||||||
|
|
||||||
namespace api.Features.QRCodes.Endpoints;
|
namespace TrackQrApi.Features.QRCodes.Endpoints;
|
||||||
|
|
||||||
public class GetQRCodeAnalyticsRequest
|
public class GetQRCodeAnalyticsRequest
|
||||||
{
|
{
|
||||||
@@ -65,16 +65,16 @@ public class GetQRCodeAnalyticsEndpoint(AppDbContext db)
|
|||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
|
|
||||||
var summary = new QRCodeAnalyticsSummary(
|
var summary = new QRCodeAnalyticsSummary(
|
||||||
TotalScans: events.Count,
|
events.Count,
|
||||||
UniqueVisitors: events.Select(e => e.IpHash).Distinct().Count()
|
events.Select(e => e.IpHash).Distinct().Count()
|
||||||
);
|
);
|
||||||
|
|
||||||
var timeSeries = events
|
var timeSeries = events
|
||||||
.GroupBy(e => e.Timestamp.Date)
|
.GroupBy(e => e.Timestamp.Date)
|
||||||
.OrderBy(g => g.Key)
|
.OrderBy(g => g.Key)
|
||||||
.Select(g => new QRCodeTimeSeriesPoint(
|
.Select(g => new QRCodeTimeSeriesPoint(
|
||||||
Date: g.Key.ToString("yyyy-MM-dd"),
|
g.Key.ToString("yyyy-MM-dd"),
|
||||||
Scans: g.Count()
|
g.Count()
|
||||||
))
|
))
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
@@ -98,14 +98,14 @@ public class GetQRCodeAnalyticsEndpoint(AppDbContext db)
|
|||||||
.ToDictionary(g => g.Key, g => g.Count());
|
.ToDictionary(g => g.Key, g => g.Count());
|
||||||
|
|
||||||
var response = new QRCodeAnalyticsResponse(
|
var response = new QRCodeAnalyticsResponse(
|
||||||
QRCodeId: qrCode.Id,
|
qrCode.Id,
|
||||||
Name: qrCode.Name,
|
qrCode.Name,
|
||||||
LinkSlug: qrCode.ShortLink?.Slug,
|
qrCode.ShortLink?.Slug,
|
||||||
Summary: summary,
|
summary,
|
||||||
TimeSeries: timeSeries,
|
timeSeries,
|
||||||
DeviceBreakdown: deviceBreakdown,
|
deviceBreakdown,
|
||||||
ReferrerBreakdown: referrerBreakdown,
|
referrerBreakdown,
|
||||||
CountryBreakdown: countryBreakdown
|
countryBreakdown
|
||||||
);
|
);
|
||||||
|
|
||||||
await HttpContext.Response.SendAsync(response, cancellation: ct);
|
await HttpContext.Response.SendAsync(response, cancellation: ct);
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using api.Data;
|
|
||||||
using api.Features.Auth.Common;
|
|
||||||
using api.Features.QRCodes.Common;
|
|
||||||
using FastEndpoints;
|
using FastEndpoints;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TrackQrApi.Data;
|
||||||
|
using TrackQrApi.Features.Auth.Common;
|
||||||
|
using TrackQrApi.Features.QRCodes.Common;
|
||||||
|
|
||||||
namespace api.Features.QRCodes.Endpoints;
|
namespace TrackQrApi.Features.QRCodes.Endpoints;
|
||||||
|
|
||||||
public class GetQRCodeRequest
|
public class GetQRCodeRequest
|
||||||
{
|
{
|
||||||
@@ -55,6 +55,6 @@ public class GetQRCodeEndpoint(AppDbContext db)
|
|||||||
qrCode.UpdatedAt
|
qrCode.UpdatedAt
|
||||||
);
|
);
|
||||||
|
|
||||||
await HttpContext.Response.SendAsync(response, 200, cancellation: ct);
|
await HttpContext.Response.SendAsync(response, cancellation: ct);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using api.Data;
|
|
||||||
using api.Features.Auth.Common;
|
|
||||||
using api.Features.QRCodes.Common;
|
|
||||||
using FastEndpoints;
|
using FastEndpoints;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TrackQrApi.Data;
|
||||||
|
using TrackQrApi.Features.Auth.Common;
|
||||||
|
using TrackQrApi.Features.QRCodes.Common;
|
||||||
|
|
||||||
namespace api.Features.QRCodes.Endpoints;
|
namespace TrackQrApi.Features.QRCodes.Endpoints;
|
||||||
|
|
||||||
public class ListQRCodesRequest
|
public class ListQRCodesRequest
|
||||||
{
|
{
|
||||||
@@ -40,15 +40,9 @@ public class ListQRCodesEndpoint(AppDbContext db)
|
|||||||
var query = db.QrCodeDesigns
|
var query = db.QrCodeDesigns
|
||||||
.Where(q => q.WorkspaceId == req.WorkspaceId);
|
.Where(q => q.WorkspaceId == req.WorkspaceId);
|
||||||
|
|
||||||
if (req.ProjectId.HasValue)
|
if (req.ProjectId.HasValue) query = query.Where(q => q.ProjectId == req.ProjectId.Value);
|
||||||
{
|
|
||||||
query = query.Where(q => q.ProjectId == req.ProjectId.Value);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (req.ShortLinkId.HasValue)
|
if (req.ShortLinkId.HasValue) query = query.Where(q => q.ShortLinkId == req.ShortLinkId.Value);
|
||||||
{
|
|
||||||
query = query.Where(q => q.ShortLinkId == req.ShortLinkId.Value);
|
|
||||||
}
|
|
||||||
|
|
||||||
var qrCodes = await query
|
var qrCodes = await query
|
||||||
.Include(q => q.ShortLink)
|
.Include(q => q.ShortLink)
|
||||||
@@ -74,6 +68,6 @@ public class ListQRCodesEndpoint(AppDbContext db)
|
|||||||
))
|
))
|
||||||
);
|
);
|
||||||
|
|
||||||
await HttpContext.Response.SendAsync(response, 200, cancellation: ct);
|
await HttpContext.Response.SendAsync(response, cancellation: ct);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using api.Data;
|
|
||||||
using api.Features.Assets.Services;
|
|
||||||
using api.Features.Auth.Common;
|
|
||||||
using api.Features.QRCodes.Common;
|
|
||||||
using api.Features.QRCodes.Services;
|
|
||||||
using FastEndpoints;
|
using FastEndpoints;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TrackQrApi.Data;
|
||||||
|
using TrackQrApi.Features.Assets.Services;
|
||||||
|
using TrackQrApi.Features.Auth.Common;
|
||||||
|
using TrackQrApi.Features.QRCodes.Common;
|
||||||
|
using TrackQrApi.Features.QRCodes.Services;
|
||||||
|
|
||||||
namespace api.Features.QRCodes.Endpoints;
|
namespace TrackQrApi.Features.QRCodes.Endpoints;
|
||||||
|
|
||||||
public class PreviewQRCodeRequest
|
public class PreviewQRCodeRequest
|
||||||
{
|
{
|
||||||
@@ -17,7 +17,10 @@ public class PreviewQRCodeRequest
|
|||||||
public int? Size { get; set; }
|
public int? Size { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class PreviewQRCodeEndpoint(AppDbContext db, IQrCodeGeneratorService qrGenerator, IAssetStorageService assetStorage)
|
public class PreviewQRCodeEndpoint(
|
||||||
|
AppDbContext db,
|
||||||
|
IQrCodeGeneratorService qrGenerator,
|
||||||
|
IAssetStorageService assetStorage)
|
||||||
: Endpoint<PreviewQRCodeRequest, QRCodePreviewResponse>
|
: Endpoint<PreviewQRCodeRequest, QRCodePreviewResponse>
|
||||||
{
|
{
|
||||||
public override void Configure()
|
public override void Configure()
|
||||||
@@ -43,7 +46,8 @@ public class PreviewQRCodeEndpoint(AppDbContext db, IQrCodeGeneratorService qrGe
|
|||||||
|
|
||||||
if (qrCode.ShortLink is null)
|
if (qrCode.ShortLink is null)
|
||||||
{
|
{
|
||||||
await HttpContext.Response.SendAsync(new MessageResponse("QR code has no associated link"), 400, cancellation: ct);
|
await HttpContext.Response.SendAsync(new MessageResponse("QR code has no associated link"), 400,
|
||||||
|
cancellation: ct);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,10 +64,7 @@ public class PreviewQRCodeEndpoint(AppDbContext db, IQrCodeGeneratorService qrGe
|
|||||||
if (qrCode.LogoAsset != null)
|
if (qrCode.LogoAsset != null)
|
||||||
{
|
{
|
||||||
var logoResult = await assetStorage.GetAsync(qrCode.LogoAsset.StorageKey);
|
var logoResult = await assetStorage.GetAsync(qrCode.LogoAsset.StorageKey);
|
||||||
if (logoResult.HasValue)
|
if (logoResult.HasValue) logoStream = logoResult.Value.Stream;
|
||||||
{
|
|
||||||
logoStream = logoResult.Value.Stream;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
@@ -71,13 +72,13 @@ public class PreviewQRCodeEndpoint(AppDbContext db, IQrCodeGeneratorService qrGe
|
|||||||
var dataUrl = qrGenerator.GenerateDataUrl(linkUrl, style, size, logoStream);
|
var dataUrl = qrGenerator.GenerateDataUrl(linkUrl, style, size, logoStream);
|
||||||
|
|
||||||
var response = new QRCodePreviewResponse(
|
var response = new QRCodePreviewResponse(
|
||||||
DataUrl: dataUrl,
|
dataUrl,
|
||||||
Format: "png",
|
"png",
|
||||||
Width: size,
|
size,
|
||||||
Height: size
|
size
|
||||||
);
|
);
|
||||||
|
|
||||||
await HttpContext.Response.SendAsync(response, 200, cancellation: ct);
|
await HttpContext.Response.SendAsync(response, cancellation: ct);
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using api.Data;
|
|
||||||
using api.Features.Auth.Common;
|
|
||||||
using api.Features.QRCodes.Common;
|
|
||||||
using FastEndpoints;
|
using FastEndpoints;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TrackQrApi.Data;
|
||||||
|
using TrackQrApi.Features.Auth.Common;
|
||||||
|
using TrackQrApi.Features.QRCodes.Common;
|
||||||
|
|
||||||
namespace api.Features.QRCodes.Endpoints;
|
namespace TrackQrApi.Features.QRCodes.Endpoints;
|
||||||
|
|
||||||
public class UpdateQRCodeRequest
|
public class UpdateQRCodeRequest
|
||||||
{
|
{
|
||||||
@@ -36,7 +36,8 @@ public class UpdateQRCodeEndpoint(AppDbContext db)
|
|||||||
.Include(q => q.Workspace)
|
.Include(q => q.Workspace)
|
||||||
.Include(q => q.ShortLink)
|
.Include(q => q.ShortLink)
|
||||||
.Include(q => q.LogoAsset)
|
.Include(q => q.LogoAsset)
|
||||||
.FirstOrDefaultAsync(q => q.Id == req.Id && q.WorkspaceId == req.WorkspaceId && q.Workspace.OwnerUserId == userId, ct);
|
.FirstOrDefaultAsync(
|
||||||
|
q => q.Id == req.Id && q.WorkspaceId == req.WorkspaceId && q.Workspace.OwnerUserId == userId, ct);
|
||||||
|
|
||||||
if (qrCode is null)
|
if (qrCode is null)
|
||||||
{
|
{
|
||||||
@@ -55,6 +56,7 @@ public class UpdateQRCodeEndpoint(AppDbContext db)
|
|||||||
await HttpContext.Response.SendAsync(new MessageResponse("Project not found"), 404, cancellation: ct);
|
await HttpContext.Response.SendAsync(new MessageResponse("Project not found"), 404, cancellation: ct);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
qrCode.ProjectId = req.ProjectId.Value;
|
qrCode.ProjectId = req.ProjectId.Value;
|
||||||
}
|
}
|
||||||
else if (req.RemoveProject == true)
|
else if (req.RemoveProject == true)
|
||||||
@@ -63,10 +65,7 @@ public class UpdateQRCodeEndpoint(AppDbContext db)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Update name if provided
|
// Update name if provided
|
||||||
if (!string.IsNullOrWhiteSpace(req.Name))
|
if (!string.IsNullOrWhiteSpace(req.Name)) qrCode.Name = req.Name;
|
||||||
{
|
|
||||||
qrCode.Name = req.Name;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle logo asset update
|
// Handle logo asset update
|
||||||
if (req.LogoAssetId.HasValue)
|
if (req.LogoAssetId.HasValue)
|
||||||
@@ -76,9 +75,11 @@ public class UpdateQRCodeEndpoint(AppDbContext db)
|
|||||||
|
|
||||||
if (!assetExists)
|
if (!assetExists)
|
||||||
{
|
{
|
||||||
await HttpContext.Response.SendAsync(new MessageResponse("Logo asset not found"), 404, cancellation: ct);
|
await HttpContext.Response.SendAsync(new MessageResponse("Logo asset not found"), 404,
|
||||||
|
cancellation: ct);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
qrCode.LogoAssetId = req.LogoAssetId.Value;
|
qrCode.LogoAssetId = req.LogoAssetId.Value;
|
||||||
// Reload the asset for the response
|
// Reload the asset for the response
|
||||||
qrCode.LogoAsset = await db.Assets.FindAsync([req.LogoAssetId.Value], ct);
|
qrCode.LogoAsset = await db.Assets.FindAsync([req.LogoAssetId.Value], ct);
|
||||||
@@ -89,10 +90,7 @@ public class UpdateQRCodeEndpoint(AppDbContext db)
|
|||||||
qrCode.LogoAsset = null;
|
qrCode.LogoAsset = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.Style != null)
|
if (req.Style != null) qrCode.StyleJson = JsonSerializer.Serialize(req.Style);
|
||||||
{
|
|
||||||
qrCode.StyleJson = JsonSerializer.Serialize(req.Style);
|
|
||||||
}
|
|
||||||
|
|
||||||
qrCode.UpdatedAt = DateTime.UtcNow;
|
qrCode.UpdatedAt = DateTime.UtcNow;
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
@@ -114,6 +112,6 @@ public class UpdateQRCodeEndpoint(AppDbContext db)
|
|||||||
qrCode.UpdatedAt
|
qrCode.UpdatedAt
|
||||||
);
|
);
|
||||||
|
|
||||||
await HttpContext.Response.SendAsync(response, 200, cancellation: ct);
|
await HttpContext.Response.SendAsync(response, cancellation: ct);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
using api.Features.QRCodes.Common;
|
using System.Text;
|
||||||
using QRCoder;
|
using QRCoder;
|
||||||
using SkiaSharp;
|
using SkiaSharp;
|
||||||
|
using TrackQrApi.Features.QRCodes.Common;
|
||||||
|
|
||||||
namespace api.Features.QRCodes.Services;
|
namespace TrackQrApi.Features.QRCodes.Services;
|
||||||
|
|
||||||
public interface IQrCodeGeneratorService
|
public interface IQrCodeGeneratorService
|
||||||
{
|
{
|
||||||
@@ -23,7 +24,7 @@ public class QrCodeGeneratorService : IQrCodeGeneratorService
|
|||||||
var moduleCount = moduleMatrix.Count;
|
var moduleCount = moduleMatrix.Count;
|
||||||
|
|
||||||
// Calculate pixels per module based on desired size (accounting for quiet zone)
|
// Calculate pixels per module based on desired size (accounting for quiet zone)
|
||||||
var totalModules = moduleCount + (style.QuietZone * 2);
|
var totalModules = moduleCount + style.QuietZone * 2;
|
||||||
var pixelsPerModule = Math.Max(4, size / totalModules);
|
var pixelsPerModule = Math.Max(4, size / totalModules);
|
||||||
var actualSize = totalModules * pixelsPerModule;
|
var actualSize = totalModules * pixelsPerModule;
|
||||||
|
|
||||||
@@ -47,29 +48,21 @@ public class QrCodeGeneratorService : IQrCodeGeneratorService
|
|||||||
|
|
||||||
var quietZoneOffset = style.QuietZone * pixelsPerModule;
|
var quietZoneOffset = style.QuietZone * pixelsPerModule;
|
||||||
|
|
||||||
for (int y = 0; y < moduleCount; y++)
|
for (var y = 0; y < moduleCount; y++)
|
||||||
{
|
for (var x = 0; x < moduleCount; x++)
|
||||||
for (int x = 0; x < moduleCount; x++)
|
if (moduleMatrix[y][x])
|
||||||
{
|
{
|
||||||
if (moduleMatrix[y][x])
|
var px = quietZoneOffset + x * pixelsPerModule;
|
||||||
{
|
var py = quietZoneOffset + y * pixelsPerModule;
|
||||||
var px = quietZoneOffset + (x * pixelsPerModule);
|
|
||||||
var py = quietZoneOffset + (y * pixelsPerModule);
|
|
||||||
|
|
||||||
// Check if this is part of a finder pattern (eyes)
|
// Check if this is part of a finder pattern (eyes)
|
||||||
var isEye = IsFinderPattern(x, y, moduleCount);
|
var isEye = IsFinderPattern(x, y, moduleCount);
|
||||||
|
|
||||||
if (isEye)
|
if (isEye)
|
||||||
{
|
DrawModule(canvas, px, py, pixelsPerModule, modulePaint, style.EyeShape);
|
||||||
DrawModule(canvas, px, py, pixelsPerModule, modulePaint, style.EyeShape);
|
else
|
||||||
}
|
DrawModule(canvas, px, py, pixelsPerModule, modulePaint, style.ModuleShape);
|
||||||
else
|
|
||||||
{
|
|
||||||
DrawModule(canvas, px, py, pixelsPerModule, modulePaint, style.ModuleShape);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Encode to PNG
|
// Encode to PNG
|
||||||
using var image = surface.Snapshot();
|
using var image = surface.Snapshot();
|
||||||
@@ -77,15 +70,84 @@ public class QrCodeGeneratorService : IQrCodeGeneratorService
|
|||||||
var qrBytes = data.ToArray();
|
var qrBytes = data.ToArray();
|
||||||
|
|
||||||
// If no logo, return the QR code as-is
|
// If no logo, return the QR code as-is
|
||||||
if (logoStream == null)
|
if (logoStream == null) return qrBytes;
|
||||||
{
|
|
||||||
return qrBytes;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Overlay logo on QR code
|
// Overlay logo on QR code
|
||||||
return OverlayLogo(qrBytes, logoStream, actualSize);
|
return OverlayLogo(qrBytes, logoStream, actualSize);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public string GenerateSvg(string content, QRCodeStyle style, int size = 512)
|
||||||
|
{
|
||||||
|
using var qrGenerator = new QRCodeGenerator();
|
||||||
|
var eccLevel = ParseEccLevel(style.ErrorCorrectionLevel);
|
||||||
|
using var qrCodeData = qrGenerator.CreateQrCode(content, eccLevel);
|
||||||
|
|
||||||
|
var moduleMatrix = qrCodeData.ModuleMatrix;
|
||||||
|
var moduleCount = moduleMatrix.Count;
|
||||||
|
|
||||||
|
// Calculate pixels per module based on desired size (accounting for quiet zone)
|
||||||
|
var totalModules = moduleCount + style.QuietZone * 2;
|
||||||
|
var pixelsPerModule = (float)size / totalModules;
|
||||||
|
var actualSize = size;
|
||||||
|
|
||||||
|
var foreground = style.ForegroundColor;
|
||||||
|
var background = style.BackgroundColor;
|
||||||
|
|
||||||
|
var svg = new StringBuilder();
|
||||||
|
svg.AppendLine(
|
||||||
|
$"<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 {actualSize} {actualSize}\" width=\"{actualSize}\" height=\"{actualSize}\">");
|
||||||
|
svg.AppendLine($" <rect width=\"100%\" height=\"100%\" fill=\"{background}\"/>");
|
||||||
|
|
||||||
|
var quietZoneOffset = style.QuietZone * pixelsPerModule;
|
||||||
|
|
||||||
|
for (var y = 0; y < moduleCount; y++)
|
||||||
|
for (var x = 0; x < moduleCount; x++)
|
||||||
|
if (moduleMatrix[y][x])
|
||||||
|
{
|
||||||
|
var px = quietZoneOffset + x * pixelsPerModule;
|
||||||
|
var py = quietZoneOffset + y * pixelsPerModule;
|
||||||
|
var isEye = IsFinderPattern(x, y, moduleCount);
|
||||||
|
var shape = isEye ? style.EyeShape : style.ModuleShape;
|
||||||
|
|
||||||
|
var padding = pixelsPerModule * 0.1f;
|
||||||
|
var moduleSize = pixelsPerModule - padding;
|
||||||
|
|
||||||
|
switch (shape.ToLowerInvariant())
|
||||||
|
{
|
||||||
|
case "circle":
|
||||||
|
case "dots":
|
||||||
|
var radius = moduleSize / 2;
|
||||||
|
var cx = px + pixelsPerModule / 2;
|
||||||
|
var cy = py + pixelsPerModule / 2;
|
||||||
|
svg.AppendLine(
|
||||||
|
$" <circle cx=\"{cx:F2}\" cy=\"{cy:F2}\" r=\"{radius:F2}\" fill=\"{foreground}\"/>");
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "rounded":
|
||||||
|
var cornerRadius = moduleSize * 0.3f;
|
||||||
|
svg.AppendLine(
|
||||||
|
$" <rect x=\"{px + padding / 2:F2}\" y=\"{py + padding / 2:F2}\" width=\"{moduleSize:F2}\" height=\"{moduleSize:F2}\" rx=\"{cornerRadius:F2}\" fill=\"{foreground}\"/>");
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "square":
|
||||||
|
default:
|
||||||
|
svg.AppendLine(
|
||||||
|
$" <rect x=\"{px + padding / 2:F2}\" y=\"{py + padding / 2:F2}\" width=\"{moduleSize:F2}\" height=\"{moduleSize:F2}\" fill=\"{foreground}\"/>");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
svg.AppendLine("</svg>");
|
||||||
|
return svg.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public string GenerateDataUrl(string content, QRCodeStyle style, int size = 256, Stream? logoStream = null)
|
||||||
|
{
|
||||||
|
var pngBytes = GeneratePng(content, style, size, logoStream);
|
||||||
|
var base64 = Convert.ToBase64String(pngBytes);
|
||||||
|
return $"data:image/png;base64,{base64}";
|
||||||
|
}
|
||||||
|
|
||||||
private static bool IsFinderPattern(int x, int y, int moduleCount)
|
private static bool IsFinderPattern(int x, int y, int moduleCount)
|
||||||
{
|
{
|
||||||
// Top-left finder pattern: 0-6, 0-6
|
// Top-left finder pattern: 0-6, 0-6
|
||||||
@@ -126,87 +188,12 @@ public class QrCodeGeneratorService : IQrCodeGeneratorService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public string GenerateSvg(string content, QRCodeStyle style, int size = 512)
|
|
||||||
{
|
|
||||||
using var qrGenerator = new QRCodeGenerator();
|
|
||||||
var eccLevel = ParseEccLevel(style.ErrorCorrectionLevel);
|
|
||||||
using var qrCodeData = qrGenerator.CreateQrCode(content, eccLevel);
|
|
||||||
|
|
||||||
var moduleMatrix = qrCodeData.ModuleMatrix;
|
|
||||||
var moduleCount = moduleMatrix.Count;
|
|
||||||
|
|
||||||
// Calculate pixels per module based on desired size (accounting for quiet zone)
|
|
||||||
var totalModules = moduleCount + (style.QuietZone * 2);
|
|
||||||
var pixelsPerModule = (float)size / totalModules;
|
|
||||||
var actualSize = size;
|
|
||||||
|
|
||||||
var foreground = style.ForegroundColor;
|
|
||||||
var background = style.BackgroundColor;
|
|
||||||
|
|
||||||
var svg = new System.Text.StringBuilder();
|
|
||||||
svg.AppendLine($"<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 {actualSize} {actualSize}\" width=\"{actualSize}\" height=\"{actualSize}\">");
|
|
||||||
svg.AppendLine($" <rect width=\"100%\" height=\"100%\" fill=\"{background}\"/>");
|
|
||||||
|
|
||||||
var quietZoneOffset = style.QuietZone * pixelsPerModule;
|
|
||||||
|
|
||||||
for (int y = 0; y < moduleCount; y++)
|
|
||||||
{
|
|
||||||
for (int x = 0; x < moduleCount; x++)
|
|
||||||
{
|
|
||||||
if (moduleMatrix[y][x])
|
|
||||||
{
|
|
||||||
var px = quietZoneOffset + (x * pixelsPerModule);
|
|
||||||
var py = quietZoneOffset + (y * pixelsPerModule);
|
|
||||||
var isEye = IsFinderPattern(x, y, moduleCount);
|
|
||||||
var shape = isEye ? style.EyeShape : style.ModuleShape;
|
|
||||||
|
|
||||||
var padding = pixelsPerModule * 0.1f;
|
|
||||||
var moduleSize = pixelsPerModule - padding;
|
|
||||||
|
|
||||||
switch (shape.ToLowerInvariant())
|
|
||||||
{
|
|
||||||
case "circle":
|
|
||||||
case "dots":
|
|
||||||
var radius = moduleSize / 2;
|
|
||||||
var cx = px + pixelsPerModule / 2;
|
|
||||||
var cy = py + pixelsPerModule / 2;
|
|
||||||
svg.AppendLine($" <circle cx=\"{cx:F2}\" cy=\"{cy:F2}\" r=\"{radius:F2}\" fill=\"{foreground}\"/>");
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "rounded":
|
|
||||||
var cornerRadius = moduleSize * 0.3f;
|
|
||||||
svg.AppendLine($" <rect x=\"{px + padding / 2:F2}\" y=\"{py + padding / 2:F2}\" width=\"{moduleSize:F2}\" height=\"{moduleSize:F2}\" rx=\"{cornerRadius:F2}\" fill=\"{foreground}\"/>");
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "square":
|
|
||||||
default:
|
|
||||||
svg.AppendLine($" <rect x=\"{px + padding / 2:F2}\" y=\"{py + padding / 2:F2}\" width=\"{moduleSize:F2}\" height=\"{moduleSize:F2}\" fill=\"{foreground}\"/>");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
svg.AppendLine("</svg>");
|
|
||||||
return svg.ToString();
|
|
||||||
}
|
|
||||||
|
|
||||||
public string GenerateDataUrl(string content, QRCodeStyle style, int size = 256, Stream? logoStream = null)
|
|
||||||
{
|
|
||||||
var pngBytes = GeneratePng(content, style, size, logoStream);
|
|
||||||
var base64 = Convert.ToBase64String(pngBytes);
|
|
||||||
return $"data:image/png;base64,{base64}";
|
|
||||||
}
|
|
||||||
|
|
||||||
private static byte[] OverlayLogo(byte[] qrBytes, Stream logoStream, int qrSize)
|
private static byte[] OverlayLogo(byte[] qrBytes, Stream logoStream, int qrSize)
|
||||||
{
|
{
|
||||||
using var qrBitmap = SKBitmap.Decode(qrBytes);
|
using var qrBitmap = SKBitmap.Decode(qrBytes);
|
||||||
using var logoBitmap = SKBitmap.Decode(logoStream);
|
using var logoBitmap = SKBitmap.Decode(logoStream);
|
||||||
|
|
||||||
if (qrBitmap == null || logoBitmap == null)
|
if (qrBitmap == null || logoBitmap == null) return qrBytes;
|
||||||
{
|
|
||||||
return qrBytes;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Logo should be about 20% of QR code size
|
// Logo should be about 20% of QR code size
|
||||||
var logoSize = (int)(qrSize * 0.2);
|
var logoSize = (int)(qrSize * 0.2);
|
||||||
@@ -234,10 +221,7 @@ public class QrCodeGeneratorService : IQrCodeGeneratorService
|
|||||||
using var resizedLogo = logoBitmap.Resize(
|
using var resizedLogo = logoBitmap.Resize(
|
||||||
new SKImageInfo(logoSize, logoSize),
|
new SKImageInfo(logoSize, logoSize),
|
||||||
new SKSamplingOptions(SKCubicResampler.Mitchell));
|
new SKSamplingOptions(SKCubicResampler.Mitchell));
|
||||||
if (resizedLogo != null)
|
if (resizedLogo != null) canvas.DrawBitmap(resizedLogo, logoX, logoY);
|
||||||
{
|
|
||||||
canvas.DrawBitmap(resizedLogo, logoX, logoY);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Encode to PNG
|
// Encode to PNG
|
||||||
using var image = surface.Snapshot();
|
using var image = surface.Snapshot();
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
using api.Data;
|
|
||||||
using api.Features.Auth.Common;
|
|
||||||
using api.Features.Events.Services;
|
|
||||||
using api.Models;
|
|
||||||
using FastEndpoints;
|
using FastEndpoints;
|
||||||
using FluentValidation;
|
using FluentValidation;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TrackQrApi.Data;
|
||||||
|
using TrackQrApi.Features.Auth.Common;
|
||||||
|
using TrackQrApi.Features.Events.Services;
|
||||||
|
using TrackQrApi.Models;
|
||||||
|
|
||||||
namespace api.Features.Redirect.Endpoints;
|
namespace TrackQrApi.Features.Redirect.Endpoints;
|
||||||
|
|
||||||
public class PasswordRedirectRequest
|
public class PasswordRedirectRequest
|
||||||
{
|
{
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
using api.Data;
|
|
||||||
using api.Features.Auth.Common;
|
|
||||||
using api.Features.Events.Services;
|
|
||||||
using api.Models;
|
|
||||||
using FastEndpoints;
|
using FastEndpoints;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TrackQrApi.Data;
|
||||||
|
using TrackQrApi.Features.Auth.Common;
|
||||||
|
using TrackQrApi.Features.Events.Services;
|
||||||
|
using TrackQrApi.Models;
|
||||||
|
|
||||||
namespace api.Features.Redirect.Endpoints;
|
namespace TrackQrApi.Features.Redirect.Endpoints;
|
||||||
|
|
||||||
public class RedirectRequest
|
public class RedirectRequest
|
||||||
{
|
{
|
||||||
@@ -78,13 +78,9 @@ public class RedirectEndpoint(AppDbContext db, IEventTrackingService eventTracki
|
|||||||
// Track event asynchronously (fire and forget)
|
// Track event asynchronously (fire and forget)
|
||||||
// If qr parameter is present, track as scan; otherwise track as click
|
// If qr parameter is present, track as scan; otherwise track as click
|
||||||
if (req.Qr.HasValue)
|
if (req.Qr.HasValue)
|
||||||
{
|
|
||||||
await eventTracking.TrackScanAsync(link.WorkspaceId, link.Id, req.Qr.Value, HttpContext);
|
await eventTracking.TrackScanAsync(link.WorkspaceId, link.Id, req.Qr.Value, HttpContext);
|
||||||
}
|
|
||||||
else
|
else
|
||||||
{
|
|
||||||
await eventTracking.TrackClickAsync(link.WorkspaceId, link.Id, HttpContext);
|
await eventTracking.TrackClickAsync(link.WorkspaceId, link.Id, HttpContext);
|
||||||
}
|
|
||||||
|
|
||||||
// Redirect to destination (302 Found)
|
// Redirect to destination (302 Found)
|
||||||
HttpContext.Response.StatusCode = StatusCodes.Status302Found;
|
HttpContext.Response.StatusCode = StatusCodes.Status302Found;
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user