Compare commits

...

14 Commits

Author SHA1 Message Date
eb8ba2aa5b feat(links): made them use env for base url api 2026-03-11 13:23:19 -04:00
14b7dadfa0 fix local dev; refactor password field 2026-03-11 04:08:32 -04:00
6dc11ce561 remove untracked filed 2026-03-06 14:47:09 -05:00
19315feb63 add missing git ignore 2026-03-06 14:45:52 -05:00
b25222cc82 current state 2026-03-06 14:42:47 -05:00
c675430fb9 feat(auth): add autocomplete to frontend 2026-02-17 15:07:39 -05:00
15d874e79b feat(api): uses fastendpoint intead of custom impl 2026-02-06 20:42:22 -05:00
ac211f86b3 feat(api): refactored to api client to be per feature 2026-02-06 20:41:07 -05:00
789e55e79d feat(auth): used FastEndpoint facilities instead of custom solution 2026-02-06 01:06:16 -05:00
19e2c22111 chore: correct namespaces and hiearchy 2026-01-31 02:16:32 -05:00
56d393e127 docs: update test plan format and structure
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 21:01:32 -05:00
0ccd26444e docs: add comprehensive test plan with 131 test cases
Test plan covers all TrakQR features organized into 13 sections:
- Authentication (16 tests)
- Workspaces (7 tests)
- Projects (5 tests)
- Short Links (17 tests)
- Redirect (8 tests)
- QR Codes (17 tests)
- Analytics (17 tests)
- Domains (7 tests)
- Settings (12 tests)
- Billing (5 tests)
- Plan Limits (5 tests)
- UI/UX (11 tests)
- Security (4 tests)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 19:22:23 -05:00
6dce383e38 docs: rewrite spec.md to match current implementation
- Restructure document with clear status tables
- Mark all implemented features with 
- Mark deferred features with 
- Add complete data model with all entities
- Add system architecture diagram
- Add comprehensive API endpoints list
- Document plan limits (Free/Pro/Business)
- List all UI pages with routes
- Add security checklist (implemented vs deferred)
- Update remaining work section with priorities

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 19:11:20 -05:00
527ee2f325 docs: clarify plan limits enforcement status
- Note that plan limits ARE enforced in most create endpoints
- Add specific task for BulkCreateLinksEndpoint plan limits check

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 18:58:10 -05:00
190 changed files with 4129 additions and 2407 deletions

View File

@@ -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
- Marketing teams in SMB/PME
- 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
- Sign up / sign in (email + password; optional SSO later)
- Email verification
- Password reset
- Basic account settings
### 1.1 Authentication & Account
Projects / Workspaces
- Default workspace per user
- Create “Projects” to organize links/QRs (e.g., “Restaurant menus”, “Flyers Q1”)
| Feature | Status | Notes |
|---------|--------|-------|
| 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
- 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
### 1.2 Workspaces & Projects
QR Code Designer
- Generate QR from a short link (default) or direct URL
- Styling:
- Colors (foreground/background)
- Error correction level (L/M/Q/H)
- Quiet zone padding
- Shape presets (modules/eyes) (start with a few presets)
- 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)
| Feature | Status | Notes |
|---------|--------|-------|
| Default workspace on signup | ✅ | Auto-created |
| Multiple workspaces | ✅ | Based on plan limits |
| Workspace CRUD | ✅ | Create, update, delete |
| Projects for organization | ✅ | Full CRUD with descriptions |
| Workspace switcher UI | ✅ | With create/manage modals |
Tracking & Analytics (MVP)
- 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
### 1.3 Short Link Management
Basic Admin
- Subscription status
- Usage quotas (links/QRs/events)
| Feature | Status | Notes |
|---------|--------|-------|
| 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”
- A/B routing, smart rules, rotation, geo routing
- Deep campaign automation
- Enterprise SSO, SCIM
- Offline QR scan tracking (impossible without network in most cases)
| Feature | Status | Notes |
|---------|--------|-------|
| Generate from short link | ✅ | Required link association |
| Foreground/background colors | ✅ | Hex color pickers |
| 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
- 1 workspace
- 25 short links
- 25 QR designs
- 10k events/month
- 1 custom QR logo upload (or allow unlimited but watermark exports)
| Feature | Status | Notes |
|---------|--------|-------|
| Click events | ✅ | From redirect endpoint |
| Scan events | ✅ | When `?qr=` param present |
| Async event logging | ✅ | Non-blocking, fire-and-forget |
| 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)
- Custom domains (13)
- Higher limits
- No watermark
- UTM templates
- Expiring links / password links (if Pro)
### 1.6 Domain Management
Business
- Multiple workspaces
- Team seats (later)
- Higher retention and export presets
| Feature | Status | Notes |
|---------|--------|-------|
| Add custom domain | ✅ | Pro/Business plans |
| 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
- id, email, password_hash, verified_at, created_at
├── id, email, password_hash
├── is_email_verified, created_at
└── Relations: Workspaces, EmailVerificationTokens, PasswordResetTokens
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
- id, workspace_id, name, created_at
├── id, workspace_id, name, description
└── created_at
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
- id
- workspace_id, project_id (nullable)
- domain_id (nullable; else default platform domain)
- slug
- destination_url
- title (nullable)
- status (active/disabled)
- expires_at (nullable)
- password_hash (nullable)
- created_at, updated_at
├── id, workspace_id, project_id (nullable), domain_id (nullable)
├── slug, destination_url, title
├── status (Active/Disabled), expires_at, password_hash
├── click_count, is_deleted, deleted_at
└── created_at, updated_at
QRCodeDesign
- id
- workspace_id, project_id (nullable)
- shortlink_id (nullable; recommended default)
- style_json (colors, shapes, ecc level, etc.)
- logo_asset_id (nullable)
- created_at, updated_at
├── id, workspace_id, project_id (nullable), link_id
├── name, style_json, logo_asset_id (nullable)
└── created_at, updated_at
Event
- id (or bigint)
- workspace_id
- shortlink_id
- qrcode_id (nullable but strongly recommended to tag scans)
- type: click | scan
- ts
- ip_hash (privacy-safe)
- user_agent
- referrer
- country_code (nullable)
- device_type (nullable)
- dedupe_key (nullable)
- raw_json (optional for debug, or drop)
├── id, workspace_id, link_id, qr_code_id (nullable)
├── type (Click/Scan), timestamp
├── ip_hash, user_agent, referrer
├── country_code, device_type, dedupe_key
└── (partitioned by month for scale)
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:
https://d.om/s/abc123?qr=<qrcode_id>
PasswordResetToken
├── 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:
- If qr present → type=scan
- Else → type=click
## 4. System Architecture
## 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:
- exists
- active
- not expired
- if password-protected → show password page
### Public Redirect Flow
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
- Create, edit, disable, delete (soft delete preferred)
- Slug uniqueness per domain
- Auto-slug generator (base62)
- Destination URL allowlist/denylist (prevent abuse)
### QR Codes
- `GET/POST /workspaces/{id}/qrcodes` - List/Create QR codes
- `GET/PUT/DELETE /workspaces/{id}/qrcodes/{qid}` - QR operations
- `GET /workspaces/{id}/qrcodes/{qid}/preview` - Get preview (data URL)
- `GET /workspaces/{id}/qrcodes/{qid}/export` - Export PNG/SVG
- `GET /workspaces/{id}/qrcodes/{qid}/analytics` - QR analytics
QR Designer
- Live preview
- Save design
- Export SVG/PNG
- Logo upload with validation (size/mime)
### Domains & Assets
- `GET/POST /workspaces/{id}/domains` - List/Add domains
- `DELETE /workspaces/{id}/domains/{did}` - Delete domain
- `POST /workspaces/{id}/domains/{did}/verify` - Verify domain
- `GET/POST /workspaces/{id}/assets` - List/Upload assets
- `DELETE /workspaces/{id}/assets/{aid}` - Delete asset
- `GET /assets/{storageKey}` - Public asset URL
Analytics
- Views for:
- Workspace overview
- 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)
### Analytics & Usage
- `GET /workspaces/{id}/analytics` - Workspace analytics
- `GET /usage` - Usage stats and limits
## 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
- Redirect endpoint P95 < 100ms (excluding DNS/TLS)
- Event write must not block redirect (use async queue if possible)
### API Keys
- `GET/POST /workspaces/{id}/api-keys` - List/Create keys
- `DELETE /workspaces/{id}/api-keys/{kid}` - Delete key
Availability
- Redirect is the critical path; should stay up even if dashboard is down
- Graceful degradation: if analytics store is down, still redirect
### Public
- `GET /{slug}` - Redirect to destination
- `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)
- Avoid storing raw IP; store hashed IP with rotating salt (e.g., monthly)
- Provide retention configuration per plan (e.g., 30/180/365 days)
## 6. UI Pages
## 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
- PostgreSQL for core entities
- Analytics:
- MVP: PostgreSQL events table partitioned by month
- Later: ClickHouse/BigQuery for scale
## 7. Security & Performance
Background Jobs
- Domain verification checks
- Event enrichment (geo/device parsing)
- Cleanup & retention tasks
### Implemented
- [x] JWT authentication with expiry
- [x] Rate limiting on auth endpoints (10 req/min)
- [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
- Workspace switcher
- Projects list
- Links list + create/edit
- QR designer (create/edit) with preview
- Analytics dashboard (overview + per link + per QR)
- Domains page (add/verify)
### High Priority
1. **API Key Authentication Middleware** - Enable programmatic access
2. **Bulk Create Plan Limits** - Check limits in bulk endpoint
3. **Custom Date Range UI** - Date picker for analytics
## 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:
- max links, max QR codes, max events/month, max custom domains
### Lower Priority
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.
- Event logging should be non-blocking:
- MVP: write to DB async (background queue) or “fire-and-forget” with retry
- Plan for domain verification:
- Require DNS TXT record or CNAME to verify ownership
- Short link collision:
- Slug uniqueness per domain enforced in DB
- Team roles/permissions (RBAC)
- A/B routing, smart rules, geo routing
- Deep campaign automation
- Enterprise SSO, SCIM
- Offline QR scan tracking

View File

@@ -269,9 +269,9 @@
- [ ] Middleware to authenticate requests using API keys
- [ ] Scope validation (read, write, admin)
2. **Plan Limits Enforcement**
- [ ] Integrate `IPlanLimitsService.CanCreate*` checks in create endpoints
- [ ] Return 403 with upgrade message when limit reached
2. **Plan Limits in Bulk Operations**
- [ ] Add plan limits check in `BulkCreateLinksEndpoint`
- Plan limits already enforced in: CreateLink, CreateQRCode, CreateWorkspace, AddDomain
3. **Custom Date Range UI**
- [ ] Add date picker to analytics pages

408
docs/test-plan.md Normal file
View 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 |
|--------|--------|------|
| | | |

View File

@@ -13,11 +13,10 @@ else
echo "PostgreSQL container does not exist. Creating..."
docker run -d \
--name TRAKQR_POSTGRES \
-v "${DATA_ROOT}/postgres:/var/lib/postgresql/data" \
-v "${DATA_ROOT}/postgres:/var/lib/postgresql" \
-p 5400:5432 \
-e POSTGRES_USER=sa \
-e POSTGRES_PASSWORD=P@ssword123! \
-e POSTGRES_DB=trakqr \
postgres:$POSTGRES_VERSION
fi

View File

@@ -1,36 +1,31 @@
using System.Net;
using System.Net.Http.Headers;
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 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 _noRedirectClient;
private readonly HttpClient _client = factory.CreateClient();
public AnalyticsEndpointTests(ApiWebApplicationFactory factory)
private readonly HttpClient _noRedirectClient = factory.CreateClient(new WebApplicationFactoryClientOptions
{
_client = factory.CreateClient();
_noRedirectClient = factory.CreateClient(new WebApplicationFactoryClientOptions
{
AllowAutoRedirect = false
});
}
AllowAutoRedirect = false
});
private async Task<(string Token, Guid WorkspaceId)> SetupAuthAndWorkspaceAsync(string email)
{
var response = await _client.PostAsJsonAsync("/auth/register", new { Email = email, Password = "password123" });
if (response.StatusCode == HttpStatusCode.Conflict)
{
response = await _client.PostAsJsonAsync("/auth/login", new { Email = email, Password = "password123" });
}
var authResult = await response.Content.ReadFromJsonAsync<AuthResponse>();
var token = authResult!.Token;
@@ -217,4 +212,4 @@ public class AnalyticsEndpointTests : IClassFixture<ApiWebApplicationFactory>
// Assert
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
}
}

View File

@@ -1,20 +1,37 @@
using api.Data;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Testcontainers.PostgreSql;
using TrackQrApi.Data;
using TrackQrApi.Models;
namespace Api.Tests;
namespace TrackQrApi.Tests;
public sealed class ApiWebApplicationFactory : WebApplicationFactory<Program>, IAsyncLifetime
{
private readonly PostgreSqlContainer _postgres = new PostgreSqlBuilder("postgres:latest")
.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()
{
@@ -47,40 +64,18 @@ public sealed class ApiWebApplicationFactory : WebApplicationFactory<Program>, I
builder.ConfigureTestServices(services =>
{
// Remove existing DbContext registration
var descriptor = services.SingleOrDefault(
d => d.ServiceType == typeof(DbContextOptions<AppDbContext>));
var descriptor = services.SingleOrDefault(d => d.ServiceType == typeof(DbContextOptions<AppDbContext>));
if (descriptor != null)
{
services.Remove(descriptor);
}
if (descriptor != null) services.Remove(descriptor);
// Add DbContext with Testcontainers connection string
services.AddDbContext<AppDbContext>(options =>
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>
/// 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>
public async Task UpgradeWorkspaceToPro(Guid workspaceId)
{
@@ -89,8 +84,8 @@ public sealed class ApiWebApplicationFactory : WebApplicationFactory<Program>, I
var workspace = await db.Workspaces.FindAsync(workspaceId);
if (workspace != null)
{
workspace.Plan = api.Models.WorkspacePlan.Pro;
workspace.Plan = WorkspacePlan.Pro;
await db.SaveChangesAsync();
}
}
}
}

View File

@@ -1,14 +1,15 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using api.Features.Auth.Common;
using api.Features.Assets.Common;
using api.Features.Workspaces.Common;
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>
{
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" });
if (response.StatusCode == HttpStatusCode.Conflict)
{
response = await _client.PostAsJsonAsync("/auth/login", new { Email = email, Password = "password123" });
}
var result = await response.Content.ReadFromJsonAsync<AuthResponse>();
var token = result!.Token;
@@ -243,4 +242,4 @@ public class AssetEndpointTests(ApiWebApplicationFactory factory)
response.StatusCode.Should().Be(HttpStatusCode.OK);
response.Headers.CacheControl!.MaxAge.Should().Be(TimeSpan.FromSeconds(31536000));
}
}
}

View File

@@ -1,11 +1,12 @@
using System.Net;
using System.Net.Http.Json;
using api.Features.Auth.Common;
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>
{
private readonly HttpClient _client = factory.CreateClient();
@@ -222,4 +223,4 @@ public class AuthControllerTests(ApiWebApplicationFactory factory)
var result = await response.Content.ReadFromJsonAsync<AuthResponse>();
result!.User.Email.Should().Be("casetest@example.com");
}
}
}

View File

@@ -1,25 +1,25 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using api.Features.Auth.Common;
using api.Features.Domains.Common;
using api.Features.Workspaces.Common;
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>
{
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" });
if (response.StatusCode == HttpStatusCode.Conflict)
{
response = await _client.PostAsJsonAsync("/auth/login", new { Email = email, Password = "password123" });
}
var result = await response.Content.ReadFromJsonAsync<AuthResponse>();
var token = result!.Token;
@@ -29,10 +29,7 @@ public class DomainEndpointTests(ApiWebApplicationFactory factory)
var workspaceId = workspaces!.Workspaces.First().Id;
// Upgrade to Pro plan for domain tests (Free plan doesn't allow custom domains)
if (upgradeToPro)
{
await factory.UpgradeWorkspaceToPro(workspaceId);
}
if (upgradeToPro) await factory.UpgradeWorkspaceToPro(workspaceId);
return (token, workspaceId);
}
@@ -45,7 +42,8 @@ public class DomainEndpointTests(ApiWebApplicationFactory factory)
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
// 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
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" });
// 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
response.StatusCode.Should().Be(HttpStatusCode.Conflict);
@@ -113,7 +112,8 @@ public class DomainEndpointTests(ApiWebApplicationFactory factory)
var (token, workspaceId) = await GetAuthAndWorkspaceAsync("get-domain@example.com");
_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>();
// Act
@@ -147,7 +147,8 @@ public class DomainEndpointTests(ApiWebApplicationFactory factory)
var (token, workspaceId) = await GetAuthAndWorkspaceAsync("delete-domain@example.com");
_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>();
// Act
@@ -168,11 +169,13 @@ public class DomainEndpointTests(ApiWebApplicationFactory factory)
var (token, workspaceId) = await GetAuthAndWorkspaceAsync("verify-domain@example.com");
_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>();
// 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
response.StatusCode.Should().Be(HttpStatusCode.OK);
@@ -189,11 +192,13 @@ public class DomainEndpointTests(ApiWebApplicationFactory factory)
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
// 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>();
// 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
response.StatusCode.Should().Be(HttpStatusCode.OK);
@@ -210,7 +215,8 @@ public class DomainEndpointTests(ApiWebApplicationFactory factory)
var (token2, _) = await GetAuthAndWorkspaceAsync("domain-user2@example.com");
_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>();
// Act - Try to access as user2
@@ -222,4 +228,4 @@ public class DomainEndpointTests(ApiWebApplicationFactory factory)
getResponse.StatusCode.Should().Be(HttpStatusCode.NotFound);
deleteResponse.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
}
}

View File

@@ -1,35 +1,30 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using api.Features.Auth.Common;
using api.Features.Links.Common;
using api.Features.Workspaces.Common;
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 EventTrackingTests : IClassFixture<ApiWebApplicationFactory>
public class EventTrackingTests(
ApiWebApplicationFactory factory)
: IClassFixture<ApiWebApplicationFactory>
{
private readonly HttpClient _client;
private readonly HttpClient _noRedirectClient;
private readonly HttpClient _client = factory.CreateClient();
public EventTrackingTests(ApiWebApplicationFactory factory)
private readonly HttpClient _noRedirectClient = factory.CreateClient(new WebApplicationFactoryClientOptions
{
_client = factory.CreateClient();
_noRedirectClient = factory.CreateClient(new WebApplicationFactoryClientOptions
{
AllowAutoRedirect = false
});
}
AllowAutoRedirect = false
});
private async Task<(string Token, Guid WorkspaceId)> SetupAuthAndWorkspaceAsync(string email)
{
var response = await _client.PostAsJsonAsync("/auth/register", new { Email = email, Password = "password123" });
if (response.StatusCode == HttpStatusCode.Conflict)
{
response = await _client.PostAsJsonAsync("/auth/login", new { Email = email, Password = "password123" });
}
var authResult = await response.Content.ReadFromJsonAsync<AuthResponse>();
var token = authResult!.Token;
@@ -82,10 +77,7 @@ public class EventTrackingTests : IClassFixture<ApiWebApplicationFactory>
// Act - Click the same link multiple times rapidly
var responses = new List<HttpResponseMessage>();
for (int i = 0; i < 5; i++)
{
responses.Add(await _noRedirectClient.GetAsync($"/{link.Slug}"));
}
for (var i = 0; i < 5; i++) responses.Add(await _noRedirectClient.GetAsync($"/{link.Slug}"));
// Assert - All should redirect successfully (deduplication happens silently)
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");
// 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
var response = await _noRedirectClient.GetAsync($"/{link.Slug}");
@@ -139,4 +132,4 @@ public class EventTrackingTests : IClassFixture<ApiWebApplicationFactory>
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Redirect);
}
}
}

View File

@@ -1,15 +1,16 @@
using System.Net;
using System.Net.Http.Headers;
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 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>
{
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" });
if (response.StatusCode == HttpStatusCode.Conflict)
{
response = await _client.PostAsJsonAsync("/auth/login", new { Email = email, Password = "password123" });
}
var authResult = await response.Content.ReadFromJsonAsync<AuthResponse>();
var token = authResult!.Token;
@@ -127,7 +126,8 @@ public class LinkEndpointTests(ApiWebApplicationFactory factory)
var (token, workspaceId) = await SetupAuthAndWorkspaceAsync("create-link-proj@example.com");
_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>();
// Act
@@ -150,8 +150,10 @@ public class LinkEndpointTests(ApiWebApplicationFactory factory)
var (token, workspaceId) = await SetupAuthAndWorkspaceAsync("list-links@example.com");
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/links", new { DestinationUrl = "https://example1.com" });
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/links", new { DestinationUrl = "https://example2.com" });
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/links",
new { DestinationUrl = "https://example1.com" });
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/links",
new { DestinationUrl = "https://example2.com" });
// Act
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");
_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>();
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/links", new { DestinationUrl = "https://in-project.com", ProjectId = project!.Id });
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/links", new { DestinationUrl = "https://no-project.com" });
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/links",
new { DestinationUrl = "https://in-project.com", ProjectId = project!.Id });
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/links",
new { DestinationUrl = "https://no-project.com" });
// Act
var response = await _client.GetAsync($"/workspaces/{workspaceId}/links?projectId={project.Id}");
@@ -372,7 +377,8 @@ public class LinkEndpointTests(ApiWebApplicationFactory factory)
// Act
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}");
// Assert - All should return NotFound (not exposing existence)
@@ -380,4 +386,4 @@ public class LinkEndpointTests(ApiWebApplicationFactory factory)
updateResponse.StatusCode.Should().Be(HttpStatusCode.NotFound);
deleteResponse.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
}
}

View File

@@ -1,14 +1,15 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using api.Features.Auth.Common;
using api.Features.Projects.Common;
using api.Features.Workspaces.Common;
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>
{
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" });
if (response.StatusCode == HttpStatusCode.Conflict)
{
response = await _client.PostAsJsonAsync("/auth/login", new { Email = email, Password = "password123" });
}
var authResult = await response.Content.ReadFromJsonAsync<AuthResponse>();
var token = authResult!.Token;
@@ -71,7 +70,8 @@ public class ProjectEndpointTests(ApiWebApplicationFactory factory)
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
// 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
response.StatusCode.Should().Be(HttpStatusCode.Created);
@@ -102,7 +102,8 @@ public class ProjectEndpointTests(ApiWebApplicationFactory factory)
var (token, workspaceId) = await SetupAuthAndWorkspaceAsync("get-proj@example.com");
_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>();
// Act
@@ -136,11 +137,13 @@ public class ProjectEndpointTests(ApiWebApplicationFactory factory)
var (token, workspaceId) = await SetupAuthAndWorkspaceAsync("update-proj@example.com");
_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>();
// 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
response.StatusCode.Should().Be(HttpStatusCode.OK);
@@ -155,7 +158,8 @@ public class ProjectEndpointTests(ApiWebApplicationFactory factory)
var (token, workspaceId) = await SetupAuthAndWorkspaceAsync("delete-proj@example.com");
_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>();
// Act
@@ -178,7 +182,8 @@ public class ProjectEndpointTests(ApiWebApplicationFactory factory)
// Create project as user1
_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>();
// Try to access as user2
@@ -186,7 +191,8 @@ public class ProjectEndpointTests(ApiWebApplicationFactory factory)
// Act
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}");
// Assert - All should return NotFound (not exposing existence)
@@ -194,4 +200,4 @@ public class ProjectEndpointTests(ApiWebApplicationFactory factory)
updateResponse.StatusCode.Should().Be(HttpStatusCode.NotFound);
deleteResponse.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
}
}

View File

@@ -1,15 +1,16 @@
using System.Net;
using System.Net.Http.Headers;
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 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>
{
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" });
if (response.StatusCode == HttpStatusCode.Conflict)
{
response = await _client.PostAsJsonAsync("/auth/login", new { Email = email, Password = "password123" });
}
var authResult = await response.Content.ReadFromJsonAsync<AuthResponse>();
var token = authResult!.Token;
@@ -139,7 +138,8 @@ public class QRCodeEndpointTests(ApiWebApplicationFactory factory)
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
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>();
// Act
@@ -173,7 +173,8 @@ public class QRCodeEndpointTests(ApiWebApplicationFactory factory)
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
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>();
// Act
@@ -201,7 +202,8 @@ public class QRCodeEndpointTests(ApiWebApplicationFactory factory)
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
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>();
// Act
@@ -223,7 +225,8 @@ public class QRCodeEndpointTests(ApiWebApplicationFactory factory)
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
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>();
// Act
@@ -244,7 +247,8 @@ public class QRCodeEndpointTests(ApiWebApplicationFactory factory)
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
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>();
// Act
@@ -263,7 +267,8 @@ public class QRCodeEndpointTests(ApiWebApplicationFactory factory)
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
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>();
// Act
@@ -280,7 +285,8 @@ public class QRCodeEndpointTests(ApiWebApplicationFactory factory)
// Arrange - Create two users
var (token1, workspaceId1) = await SetupAuthAndWorkspaceAsync("qr-user1@example.com");
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 (token2, _) = await SetupAuthAndWorkspaceAsync("qr-user2@example.com");
@@ -296,4 +302,4 @@ public class QRCodeEndpointTests(ApiWebApplicationFactory factory)
updateResponse.StatusCode.Should().Be(HttpStatusCode.NotFound);
deleteResponse.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
}
}

View File

@@ -1,35 +1,32 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using api.Features.Auth.Common;
using api.Features.Links.Common;
using api.Features.Workspaces.Common;
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 _noRedirectClient;
private readonly HttpClient _client = factory.CreateClient();
public RedirectEndpointTests(ApiWebApplicationFactory factory)
private readonly HttpClient _noRedirectClient = factory.CreateClient(new WebApplicationFactoryClientOptions
{
_client = factory.CreateClient();
// Create a client that doesn't follow redirects
_noRedirectClient = factory.CreateClient(new Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactoryClientOptions
{
AllowAutoRedirect = false
});
}
AllowAutoRedirect = false
});
// Create a client that doesn't follow redirects
private async Task<(string Token, Guid WorkspaceId)> SetupAuthAndWorkspaceAsync(string email)
{
var response = await _client.PostAsJsonAsync("/auth/register", new { Email = email, Password = "password123" });
if (response.StatusCode == HttpStatusCode.Conflict)
{
response = await _client.PostAsJsonAsync("/auth/login", new { Email = email, Password = "password123" });
}
var authResult = await response.Content.ReadFromJsonAsync<AuthResponse>();
var token = authResult!.Token;
@@ -42,7 +39,8 @@ public class RedirectEndpointTests : IClassFixture<ApiWebApplicationFactory>
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
{
@@ -129,7 +127,7 @@ public class RedirectEndpointTests : IClassFixture<ApiWebApplicationFactory>
{
// Arrange
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
var response = await _client.GetAsync($"/{link.Slug}");
@@ -144,7 +142,7 @@ public class RedirectEndpointTests : IClassFixture<ApiWebApplicationFactory>
{
// Arrange
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
var response = await _noRedirectClient.PostAsJsonAsync($"/{link.Slug}", new { Password = "secret123" });
@@ -159,7 +157,7 @@ public class RedirectEndpointTests : IClassFixture<ApiWebApplicationFactory>
{
// Arrange
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
var response = await _client.PostAsJsonAsync($"/{link.Slug}", new { Password = "wrongpassword" });
@@ -173,7 +171,7 @@ public class RedirectEndpointTests : IClassFixture<ApiWebApplicationFactory>
{
// Arrange
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
var response = await _client.PostAsJsonAsync($"/{link.Slug}", new { Password = "" });
@@ -212,4 +210,4 @@ public class RedirectEndpointTests : IClassFixture<ApiWebApplicationFactory>
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Redirect);
}
}
}

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

View File

@@ -1,24 +1,25 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using api.Features.Auth.Common;
using api.Features.Workspaces.Common;
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>
{
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" });
if (response.StatusCode == HttpStatusCode.Conflict)
{
response = await _client.PostAsJsonAsync("/auth/login", new { Email = email, Password = "password123" });
}
var result = await response.Content.ReadFromJsonAsync<AuthResponse>();
var token = result!.Token;
@@ -27,10 +28,7 @@ public class WorkspaceEndpointTests(ApiWebApplicationFactory factory)
var workspaces = await workspacesResponse.Content.ReadFromJsonAsync<WorkspaceListResponse>();
var workspaceId = workspaces!.Workspaces.First().Id;
if (upgradeToPro)
{
await factory.UpgradeWorkspaceToPro(workspaceId);
}
if (upgradeToPro) await factory.UpgradeWorkspaceToPro(workspaceId);
return (token, workspaceId);
}
@@ -72,7 +70,7 @@ public class WorkspaceEndpointTests(ApiWebApplicationFactory factory)
public async Task CreateWorkspace_WithValidData_ReturnsCreated()
{
// 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);
// Act
@@ -152,7 +150,7 @@ public class WorkspaceEndpointTests(ApiWebApplicationFactory factory)
public async Task DeleteWorkspace_WithValidId_ReturnsSuccess()
{
// 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);
var createResponse = await _client.PostAsJsonAsync("/workspaces", new { Name = "To Delete" });
@@ -194,4 +192,4 @@ public class WorkspaceEndpointTests(ApiWebApplicationFactory factory)
updateResponse.StatusCode.Should().Be(HttpStatusCode.NotFound);
deleteResponse.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
}
}

View File

@@ -0,0 +1,4 @@
<Solution>
<Project Path="TrackQrApi.Tests\TrackQrApi.Tests.csproj"/>
<Project Path="TrackQrApi\TrackQrApi.csproj"/>
</Solution>

482
src/TrackApi/TrackQrApi/.gitignore vendored Normal file
View File

@@ -0,0 +1,482 @@
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
##
## Get latest from `dotnet new gitignore`
# dotenv files
.env
# User-specific files
*.rsuser
*.suo
*.user
*.userosscache
*.sln.docstates
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
# Mono auto generated files
mono_crash.*
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
[Ww][Ii][Nn]32/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
bld/
[Bb]in/
[Oo]bj/
[Ll]og/
[Ll]ogs/
# Visual Studio 2015/2017 cache/options directory
.vs/
# Uncomment if you have tasks that create the project's static files in wwwroot
#wwwroot/
# Visual Studio 2017 auto generated files
Generated\ Files/
# MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
# NUnit
*.VisualState.xml
TestResult.xml
nunit-*.xml
# Build Results of an ATL Project
[Dd]ebugPS/
[Rr]eleasePS/
dlldata.c
# Benchmark Results
BenchmarkDotNet.Artifacts/
# .NET
project.lock.json
project.fragment.lock.json
artifacts/
# Tye
.tye/
# ASP.NET Scaffolding
ScaffoldingReadMe.txt
# StyleCop
StyleCopReport.xml
# Files built by Visual Studio
*_i.c
*_p.c
*_h.h
*.ilk
*.meta
*.obj
*.iobj
*.pch
*.pdb
*.ipdb
*.pgc
*.pgd
*.rsp
# but not Directory.Build.rsp, as it configures directory-level build defaults
!Directory.Build.rsp
*.sbr
*.tlb
*.tli
*.tlh
*.tmp
*.tmp_proj
*_wpftmp.csproj
*.log
*.tlog
*.vspscc
*.vssscc
.builds
*.pidb
*.svclog
*.scc
# Chutzpah Test files
_Chutzpah*
# Visual C++ cache files
ipch/
*.aps
*.ncb
*.opendb
*.opensdf
*.sdf
*.cachefile
*.VC.db
*.VC.VC.opendb
# Visual Studio profiler
*.psess
*.vsp
*.vspx
*.sap
# Visual Studio Trace Files
*.e2e
# TFS 2012 Local Workspace
$tf/
# Guidance Automation Toolkit
*.gpState
# ReSharper is a .NET coding add-in
_ReSharper*/
*.[Rr]e[Ss]harper
*.DotSettings.user
# TeamCity is a build add-in
_TeamCity*
# DotCover is a Code Coverage Tool
*.dotCover
# AxoCover is a Code Coverage Tool
.axoCover/*
!.axoCover/settings.json
# Coverlet is a free, cross platform Code Coverage Tool
coverage*.json
coverage*.xml
coverage*.info
# Visual Studio code coverage results
*.coverage
*.coveragexml
# NCrunch
_NCrunch_*
.*crunch*.local.xml
nCrunchTemp_*
# MightyMoose
*.mm.*
AutoTest.Net/
# Web workbench (sass)
.sass-cache/
# Installshield output folder
[Ee]xpress/
# DocProject is a documentation generator add-in
DocProject/buildhelp/
DocProject/Help/*.HxT
DocProject/Help/*.HxC
DocProject/Help/*.hhc
DocProject/Help/*.hhk
DocProject/Help/*.hhp
DocProject/Help/Html2
DocProject/Help/html
# Click-Once directory
publish/
# Publish Web Output
*.[Pp]ublish.xml
*.azurePubxml
# Note: Comment the next line if you want to checkin your web deploy settings,
# but database connection strings (with potential passwords) will be unencrypted
*.pubxml
*.publishproj
# Microsoft Azure Web App publish settings. Comment the next line if you want to
# checkin your Azure Web App publish settings, but sensitive information contained
# in these scripts will be unencrypted
PublishScripts/
# NuGet Packages
*.nupkg
# NuGet Symbol Packages
*.snupkg
# The packages folder can be ignored because of Package Restore
**/[Pp]ackages/*
# except build/, which is used as an MSBuild target.
!**/[Pp]ackages/build/
# Uncomment if necessary however generally it will be regenerated when needed
#!**/[Pp]ackages/repositories.config
# NuGet v3's project.json files produces more ignorable files
*.nuget.props
*.nuget.targets
# Microsoft Azure Build Output
csx/
*.build.csdef
# Microsoft Azure Emulator
ecf/
rcf/
# Windows Store app package directories and files
AppPackages/
BundleArtifacts/
Package.StoreAssociation.xml
_pkginfo.txt
*.appx
*.appxbundle
*.appxupload
# Visual Studio cache files
# files ending in .cache can be ignored
*.[Cc]ache
# but keep track of directories ending in .cache
!?*.[Cc]ache/
# Others
ClientBin/
~$*
*~
*.dbmdl
*.dbproj.schemaview
*.jfm
*.pfx
*.publishsettings
orleans.codegen.cs
# Including strong name files can present a security risk
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
#*.snk
# Since there are multiple workflows, uncomment next line to ignore bower_components
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
#bower_components/
# RIA/Silverlight projects
Generated_Code/
# Backup & report files from converting an old project file
# to a newer Visual Studio version. Backup files are not needed,
# because we have git ;-)
_UpgradeReport_Files/
Backup*/
UpgradeLog*.XML
UpgradeLog*.htm
ServiceFabricBackup/
*.rptproj.bak
# SQL Server files
*.mdf
*.ldf
*.ndf
# Business Intelligence projects
*.rdl.data
*.bim.layout
*.bim_*.settings
*.rptproj.rsuser
*- [Bb]ackup.rdl
*- [Bb]ackup ([0-9]).rdl
*- [Bb]ackup ([0-9][0-9]).rdl
# Microsoft Fakes
FakesAssemblies/
# GhostDoc plugin setting file
*.GhostDoc.xml
# Node.js Tools for Visual Studio
.ntvs_analysis.dat
node_modules/
# Visual Studio 6 build log
*.plg
# Visual Studio 6 workspace options file
*.opt
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
*.vbw
# Visual Studio 6 auto-generated project file (contains which files were open etc.)
*.vbp
# Visual Studio 6 workspace and project file (working project files containing files to include in project)
*.dsw
*.dsp
# Visual Studio 6 technical files
*.ncb
*.aps
# Visual Studio LightSwitch build output
**/*.HTMLClient/GeneratedArtifacts
**/*.DesktopClient/GeneratedArtifacts
**/*.DesktopClient/ModelManifest.xml
**/*.Server/GeneratedArtifacts
**/*.Server/ModelManifest.xml
_Pvt_Extensions
# Paket dependency manager
.paket/paket.exe
paket-files/
# FAKE - F# Make
.fake/
# CodeRush personal settings
.cr/personal
# Python Tools for Visual Studio (PTVS)
__pycache__/
*.pyc
# Cake - Uncomment if you are using it
# tools/**
# !tools/packages.config
# Tabs Studio
*.tss
# Telerik's JustMock configuration file
*.jmconfig
# BizTalk build output
*.btp.cs
*.btm.cs
*.odx.cs
*.xsd.cs
# OpenCover UI analysis results
OpenCover/
# Azure Stream Analytics local run output
ASALocalRun/
# MSBuild Binary and Structured Log
*.binlog
# NVidia Nsight GPU debugger configuration file
*.nvuser
# MFractors (Xamarin productivity tool) working folder
.mfractor/
# Local History for Visual Studio
.localhistory/
# Visual Studio History (VSHistory) files
.vshistory/
# BeatPulse healthcheck temp database
healthchecksdb
# Backup folder for Package Reference Convert tool in Visual Studio 2017
MigrationBackup/
# Ionide (cross platform F# VS Code tools) working folder
.ionide/
# Fody - auto-generated XML schema
FodyWeavers.xsd
# VS Code files for those working on multiple tools
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
*.code-workspace
# Local History for Visual Studio Code
.history/
# Windows Installer files from build outputs
*.cab
*.msi
*.msix
*.msm
*.msp
# JetBrains Rider
*.sln.iml
.idea/
##
## Visual studio for Mac
##
# globs
Makefile.in
*.userprefs
*.usertasks
config.make
config.status
aclocal.m4
install-sh
autom4te.cache/
*.tar.gz
tarballs/
test-results/
# content below from: https://github.com/github/gitignore/blob/main/Global/macOS.gitignore
# General
.DS_Store
.AppleDouble
.LSOverride
# Icon must end with two \r
Icon
# Thumbnails
._*
# Files that might appear in the root of a volume
.DocumentRevisions-V100
.fseventsd
.Spotlight-V100
.TemporaryItems
.Trashes
.VolumeIcon.icns
.com.apple.timemachine.donotpresent
# Directories potentially created on remote AFP share
.AppleDB
.AppleDesktop
Network Trash Folder
Temporary Items
.apdisk
# content below from: https://github.com/github/gitignore/blob/main/Global/Windows.gitignore
# Windows thumbnail cache files
Thumbs.db
ehthumbs.db
ehthumbs_vista.db
# Dump file
*.stackdump
# Folder config file
[Dd]esktop.ini
# Recycle Bin used on file shares
$RECYCLE.BIN/
# Windows Installer files
*.cab
*.msi
*.msix
*.msm
*.msp
# Windows shortcuts
*.lnk
# Vim temporary swap files
*.swp

View File

@@ -1,9 +1,9 @@
using api.Models;
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)
{
public DbSet<User> Users => Set<User>();
@@ -225,4 +225,4 @@ public class AppDbContext(DbContextOptions<AppDbContext> options)
.OnDelete(DeleteBehavior.Cascade);
});
}
}
}

View File

@@ -1,4 +1,4 @@
namespace api.Features.Analytics.Common;
namespace TrackQrApi.Features.Analytics.Common;
public record AnalyticsSummary(
int TotalClicks,
@@ -37,4 +37,4 @@ public record LinkAnalyticsResponse(
IEnumerable<BreakdownItem> DeviceBreakdown,
IEnumerable<BreakdownItem> ReferrerBreakdown,
IEnumerable<BreakdownItem> CountryBreakdown
);
);

View File

@@ -1,12 +1,12 @@
using System.Security.Claims;
using api.Data;
using api.Features.Analytics.Common;
using api.Features.Auth.Common;
using api.Models;
using FastEndpoints;
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
{
@@ -59,26 +59,20 @@ public class LinkAnalyticsEndpoint(AppDbContext db)
var eventsQuery = db.Events
.Where(e => e.ShortLinkId == req.Id);
if (startDate.HasValue)
{
eventsQuery = eventsQuery.Where(e => e.Timestamp >= startDate.Value);
}
if (startDate.HasValue) eventsQuery = eventsQuery.Where(e => e.Timestamp >= startDate.Value);
if (endDate.HasValue)
{
eventsQuery = eventsQuery.Where(e => e.Timestamp < endDate.Value);
}
if (endDate.HasValue) eventsQuery = eventsQuery.Where(e => e.Timestamp < endDate.Value);
var events = await eventsQuery.ToListAsync(ct);
var totalEvents = events.Count;
// Build summary
var summary = new AnalyticsSummary(
TotalClicks: events.Count(e => e.Type == EventType.Click),
TotalScans: events.Count(e => e.Type == EventType.Scan),
UniqueVisitors: events.Select(e => e.IpHash).Distinct().Count(),
FirstEvent: events.MinBy(e => e.Timestamp)?.Timestamp,
LastEvent: events.MaxBy(e => e.Timestamp)?.Timestamp
events.Count(e => e.Type == EventType.Click),
events.Count(e => e.Type == EventType.Scan),
events.Select(e => e.IpHash).Distinct().Count(),
events.MinBy(e => e.Timestamp)?.Timestamp,
events.MaxBy(e => e.Timestamp)?.Timestamp
);
// Build time series
@@ -86,9 +80,9 @@ public class LinkAnalyticsEndpoint(AppDbContext db)
.GroupBy(e => e.Timestamp.Date)
.OrderBy(g => g.Key)
.Select(g => new TimeSeriesPoint(
Date: g.Key,
Clicks: g.Count(e => e.Type == EventType.Click),
Scans: g.Count(e => e.Type == EventType.Scan)
g.Key,
g.Count(e => e.Type == EventType.Click),
g.Count(e => e.Type == EventType.Scan)
))
.ToList();
@@ -131,16 +125,16 @@ public class LinkAnalyticsEndpoint(AppDbContext db)
.ToList();
var response = new LinkAnalyticsResponse(
LinkId: link.Id,
Slug: link.Slug,
Summary: summary,
TimeSeries: timeSeries,
DeviceBreakdown: deviceBreakdown,
ReferrerBreakdown: referrerBreakdown,
CountryBreakdown: countryBreakdown
link.Id,
link.Slug,
summary,
timeSeries,
deviceBreakdown,
referrerBreakdown,
countryBreakdown
);
await HttpContext.Response.SendAsync(response, 200, cancellation: ct);
await HttpContext.Response.SendAsync(response, cancellation: ct);
}
private static DateTime? GetStartDate(string? period)
@@ -166,4 +160,4 @@ public class LinkAnalyticsEndpoint(AppDbContext db)
return url.Length > 50 ? url[..50] : url;
}
}
}
}

View File

@@ -1,12 +1,12 @@
using System.Security.Claims;
using api.Data;
using api.Features.Analytics.Common;
using api.Features.Auth.Common;
using api.Models;
using FastEndpoints;
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
{
@@ -56,26 +56,20 @@ public class WorkspaceAnalyticsEndpoint(AppDbContext db)
var eventsQuery = db.Events
.Where(e => e.WorkspaceId == req.WorkspaceId);
if (startDate.HasValue)
{
eventsQuery = eventsQuery.Where(e => e.Timestamp >= startDate.Value);
}
if (startDate.HasValue) eventsQuery = eventsQuery.Where(e => e.Timestamp >= startDate.Value);
if (endDate.HasValue)
{
eventsQuery = eventsQuery.Where(e => e.Timestamp < endDate.Value);
}
if (endDate.HasValue) eventsQuery = eventsQuery.Where(e => e.Timestamp < endDate.Value);
var events = await eventsQuery.ToListAsync(ct);
var totalEvents = events.Count;
// Get summary
var summary = new AnalyticsSummary(
TotalClicks: events.Count(e => e.Type == EventType.Click),
TotalScans: events.Count(e => e.Type == EventType.Scan),
UniqueVisitors: events.Select(e => e.IpHash).Distinct().Count(),
FirstEvent: events.Count > 0 ? events.Min(e => e.Timestamp) : null,
LastEvent: events.Count > 0 ? events.Max(e => e.Timestamp) : null
events.Count(e => e.Type == EventType.Click),
events.Count(e => e.Type == EventType.Scan),
events.Select(e => e.IpHash).Distinct().Count(),
events.Count > 0 ? events.Min(e => e.Timestamp) : null,
events.Count > 0 ? events.Max(e => e.Timestamp) : null
);
// Get time series
@@ -83,9 +77,9 @@ public class WorkspaceAnalyticsEndpoint(AppDbContext db)
.GroupBy(e => e.Timestamp.Date)
.OrderBy(g => g.Key)
.Select(g => new TimeSeriesPoint(
Date: g.Key,
Clicks: g.Count(e => e.Type == EventType.Click),
Scans: g.Count(e => e.Type == EventType.Scan)
g.Key,
g.Count(e => e.Type == EventType.Click),
g.Count(e => e.Type == EventType.Scan)
))
.ToList();
@@ -146,15 +140,15 @@ public class WorkspaceAnalyticsEndpoint(AppDbContext db)
.ToList();
var response = new WorkspaceAnalyticsResponse(
Summary: summary,
TimeSeries: timeSeries,
TopLinks: topLinks,
DeviceBreakdown: deviceBreakdown,
ReferrerBreakdown: referrerBreakdown,
CountryBreakdown: countryBreakdown
summary,
timeSeries,
topLinks,
deviceBreakdown,
referrerBreakdown,
countryBreakdown
);
await HttpContext.Response.SendAsync(response, 200, cancellation: ct);
await HttpContext.Response.SendAsync(response, cancellation: ct);
}
private static DateTime? GetStartDate(string? period)
@@ -180,4 +174,4 @@ public class WorkspaceAnalyticsEndpoint(AppDbContext db)
return url.Length > 50 ? url[..50] : url;
}
}
}
}

View File

@@ -1,12 +1,13 @@
using System.Security.Claims;
using System.Security.Cryptography;
using api.Data;
using api.Features.Auth.Common;
using api.Models;
using System.Text;
using FastEndpoints;
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
{
@@ -32,7 +33,7 @@ public class CreateApiKeyEndpoint(AppDbContext db)
{
public override void Configure()
{
Post("/workspaces/{WorkspaceId}/api-keys");
Post("/workspaces/{WorkspaceId}/TrackQrApi-keys");
}
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);
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;
}
@@ -73,7 +75,7 @@ public class CreateApiKeyEndpoint(AppDbContext db)
Scopes = req.Scopes,
ExpiresAt = req.ExpiresAt,
CreatedAt = DateTime.UtcNow,
IsActive = true,
IsActive = true
};
db.ApiKeys.Add(apiKey);
@@ -87,7 +89,7 @@ public class CreateApiKeyEndpoint(AppDbContext db)
KeyPrefix = keyPrefix,
Scopes = apiKey.Scopes,
ExpiresAt = apiKey.ExpiresAt,
CreatedAt = apiKey.CreatedAt,
CreatedAt = apiKey.CreatedAt
};
await HttpContext.Response.SendAsync(response, 201, cancellation: ct);
@@ -95,8 +97,8 @@ public class CreateApiKeyEndpoint(AppDbContext db)
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);
return Convert.ToHexString(hash).ToLower();
}
}
}

View File

@@ -1,10 +1,10 @@
using System.Security.Claims;
using api.Data;
using api.Features.Auth.Common;
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using TrackQrApi.Data;
using TrackQrApi.Features.Auth.Common;
namespace api.Features.ApiKeys.Endpoints;
namespace TrackQrApi.Features.ApiKeys.Endpoints;
public class DeleteApiKeyRequest
{
@@ -17,7 +17,7 @@ public class DeleteApiKeyEndpoint(AppDbContext db)
{
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)
@@ -46,6 +46,6 @@ public class DeleteApiKeyEndpoint(AppDbContext db)
db.ApiKeys.Remove(apiKey);
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);
}
}
}

View File

@@ -1,10 +1,10 @@
using System.Security.Claims;
using api.Data;
using api.Features.Auth.Common;
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using TrackQrApi.Data;
using TrackQrApi.Features.Auth.Common;
namespace api.Features.ApiKeys.Endpoints;
namespace TrackQrApi.Features.ApiKeys.Endpoints;
public class ListApiKeysRequest
{
@@ -33,7 +33,7 @@ public class ListApiKeysEndpoint(AppDbContext db)
{
public override void Configure()
{
Get("/workspaces/{WorkspaceId}/api-keys");
Get("/workspaces/{WorkspaceId}/TrackQrApi-keys");
}
public override async Task HandleAsync(ListApiKeysRequest req, CancellationToken ct)
@@ -62,11 +62,11 @@ public class ListApiKeysEndpoint(AppDbContext db)
ExpiresAt = k.ExpiresAt,
LastUsedAt = k.LastUsedAt,
CreatedAt = k.CreatedAt,
IsActive = k.IsActive,
IsActive = k.IsActive
})
.ToListAsync(ct);
var response = new ListApiKeysResponse { ApiKeys = apiKeys };
await HttpContext.Response.SendAsync(response, 200, cancellation: ct);
await HttpContext.Response.SendAsync(response, cancellation: ct);
}
}
}

View File

@@ -1,4 +1,4 @@
namespace api.Features.Assets.Common;
namespace TrackQrApi.Features.Assets.Common;
public record AssetResponse(
Guid Id,
@@ -12,4 +12,4 @@ public record AssetResponse(
public record AssetListResponse(
IEnumerable<AssetResponse> Assets
);
);

View File

@@ -1,11 +1,11 @@
using System.Security.Claims;
using api.Data;
using api.Features.Assets.Services;
using api.Features.Auth.Common;
using FastEndpoints;
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
{
@@ -27,7 +27,8 @@ public class DeleteAssetEndpoint(AppDbContext db, IAssetStorageService storage)
var asset = await db.Assets
.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)
{
@@ -55,6 +56,6 @@ public class DeleteAssetEndpoint(AppDbContext db, IAssetStorageService storage)
db.Assets.Remove(asset);
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);
}
}
}

View File

@@ -1,10 +1,10 @@
using api.Data;
using api.Features.Assets.Services;
using api.Features.Auth.Common;
using FastEndpoints;
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
{
@@ -50,4 +50,4 @@ public class GetAssetEndpoint(AppDbContext db, IAssetStorageService storage)
await stream.CopyToAsync(HttpContext.Response.Body, ct);
await stream.DisposeAsync();
}
}
}

View File

@@ -1,12 +1,12 @@
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 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
{
@@ -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);
}
}
}

View File

@@ -1,13 +1,13 @@
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 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
{
@@ -39,9 +39,8 @@ public class UploadAssetEndpoint(AppDbContext db, IAssetStorageService storage)
}
// Get file from form
IFormFile? file = req.File;
var file = req.File;
if (file is null)
{
try
{
file = HttpContext.Request.Form.Files.FirstOrDefault();
@@ -50,7 +49,6 @@ public class UploadAssetEndpoint(AppDbContext db, IAssetStorageService storage)
{
// Form access failed - no file uploaded
}
}
if (file is null || file.Length == 0)
{
@@ -111,4 +109,4 @@ public class UploadAssetEndpoint(AppDbContext db, IAssetStorageService storage)
await HttpContext.Response.SendAsync(response, 201, cancellation: ct);
}
}
}

View File

@@ -1,4 +1,4 @@
namespace api.Features.Assets.Services;
namespace TrackQrApi.Features.Assets.Services;
public interface IAssetStorageService
{
@@ -19,10 +19,7 @@ public class LocalAssetStorageService : IAssetStorageService
_basePath = configuration["Storage:LocalPath"] ?? Path.Combine(Directory.GetCurrentDirectory(), "uploads");
// Ensure directory exists
if (!Directory.Exists(_basePath))
{
Directory.CreateDirectory(_basePath);
}
if (!Directory.Exists(_basePath)) Directory.CreateDirectory(_basePath);
}
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);
if (!File.Exists(filePath))
{
return Task.FromResult<(Stream, string)?>(null);
}
if (!File.Exists(filePath)) return Task.FromResult<(Stream, string)?>(null);
var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read);
var contentType = GetContentType(storageKey);
@@ -87,4 +81,4 @@ public class LocalAssetStorageService : IAssetStorageService
_ => "application/octet-stream"
};
}
}
}

View File

@@ -1,4 +1,4 @@
namespace api.Features.Auth.Common;
namespace TrackQrApi.Features.Auth.Common;
public record AuthResponse(
string Token,
@@ -12,4 +12,4 @@ public record UserInfo(
bool IsVerified
);
public record MessageResponse(string Message);
public record MessageResponse(string Message);

View File

@@ -1,10 +1,10 @@
using System.Security.Claims;
using api.Data;
using api.Features.Auth.Common;
using FastEndpoints;
using FluentValidation;
using TrackQrApi.Data;
using TrackQrApi.Features.Auth.Common;
namespace api.Features.Auth.Endpoints;
namespace TrackQrApi.Features.Auth.Endpoints;
public class ChangePasswordRequest
{
@@ -46,7 +46,8 @@ public class ChangePasswordEndpoint(AppDbContext db) : Endpoint<ChangePasswordRe
// Verify current password
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;
}
@@ -56,4 +57,4 @@ public class ChangePasswordEndpoint(AppDbContext db) : Endpoint<ChangePasswordRe
await HttpContext.Response.SendAsync(new MessageResponse("Password changed successfully"), cancellation: ct);
}
}
}

View File

@@ -1,11 +1,11 @@
using System.Security.Claims;
using api.Data;
using api.Features.Auth.Common;
using FastEndpoints;
using FluentValidation;
using Microsoft.EntityFrameworkCore;
using TrackQrApi.Data;
using TrackQrApi.Features.Auth.Common;
namespace api.Features.Auth.Endpoints;
namespace TrackQrApi.Features.Auth.Endpoints;
public class DeleteAccountRequest
{
@@ -56,4 +56,4 @@ public class DeleteAccountEndpoint(AppDbContext db) : Endpoint<DeleteAccountRequ
await HttpContext.Response.SendAsync(new MessageResponse("Account deleted successfully"), cancellation: ct);
}
}
}

View File

@@ -1,13 +1,13 @@
using System.Security.Cryptography;
using api.Data;
using api.Features.Auth.Common;
using api.Features.Email.Services;
using api.Models;
using FastEndpoints;
using FluentValidation;
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
{
@@ -46,10 +46,7 @@ public class ForgotPasswordEndpoint(AppDbContext db, IEmailService emailService)
.Where(t => t.UserId == user.Id && !t.Used)
.ToListAsync(ct);
foreach (var token in existingTokens)
{
token.Used = true;
}
foreach (var token in existingTokens) token.Used = true;
// Generate new token
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
await HttpContext.Response.SendAsync(
new MessageResponse("If the email exists, a reset link will be sent"),
200,
cancellation: ct);
}
}
}

View File

@@ -1,9 +1,9 @@
using System.Security.Claims;
using api.Data;
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using TrackQrApi.Data;
namespace api.Features.Auth.Endpoints;
namespace TrackQrApi.Features.Auth.Endpoints;
public record ProfileResponse(
Guid Id,
@@ -41,4 +41,4 @@ public class GetProfileEndpoint(AppDbContext db) : EndpointWithoutRequest<Profil
await HttpContext.Response.SendAsync(user, cancellation: ct);
}
}
}

View File

@@ -1,16 +1,17 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using api.Data;
using api.Features.Auth.Common;
using api.Features.Auth.Settings;
using FastEndpoints;
using FastEndpoints.Security;
using FluentValidation;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
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
{
@@ -31,7 +32,7 @@ public class LoginValidator : Validator<LoginRequest>
}
}
public class LoginEndpoint(AppDbContext db, IOptions<JwtSettings> jwtSettings)
public class LoginEndpoint(AppDbContext db, IOptions<JwtSettings> jwtSettings)
: Endpoint<LoginRequest, AuthResponse>
{
private readonly JwtSettings _jwtSettings = jwtSettings.Value;
@@ -50,37 +51,36 @@ public class LoginEndpoint(AppDbContext db, IOptions<JwtSettings> jwtSettings)
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;
}
Logger.LogInformation("User logged in: {Email}", normalizedEmail);
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()),
new Claim(JwtRegisteredClaimNames.Email, user.Email),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
};
var token = new JwtSecurityToken(
issuer: _jwtSettings.Issuer,
audience: _jwtSettings.Audience,
claims: claims,
expires: expiresAt,
signingCredentials: credentials
);
o.SigningKey = _jwtSettings.Secret;
o.Issuer = _jwtSettings.Issuer;
o.Audience = _jwtSettings.Audience;
o.ExpireAt = expiresAt;
//o.User.Roles.Add("Manager", "Auditor");
o.User.Claims.Add(
new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
new Claim(JwtRegisteredClaimNames.Sub, user.Id.ToString()),
new Claim(JwtRegisteredClaimNames.Email, user.Email),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
);
});
var response = new AuthResponse(
Token: new JwtSecurityTokenHandler().WriteToken(token),
ExpiresAt: expiresAt,
User: new UserInfo(user.Id, user.Email, user.VerifiedAt.HasValue)
jwtToken,
expiresAt,
new UserInfo(user.Id, user.Email, user.VerifiedAt.HasValue)
);
await HttpContext.Response.SendAsync(response, 200, cancellation: ct);
await HttpContext.Response.SendAsync(response, cancellation: ct);
}
}
}

View File

@@ -2,18 +2,18 @@ using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Security.Cryptography;
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 FluentValidation;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
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
{
@@ -55,7 +55,8 @@ public class RegisterEndpoint(AppDbContext db, IOptions<JwtSettings> jwtSettings
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;
}
@@ -122,17 +123,17 @@ public class RegisterEndpoint(AppDbContext db, IOptions<JwtSettings> jwtSettings
};
var token = new JwtSecurityToken(
issuer: _jwtSettings.Issuer,
audience: _jwtSettings.Audience,
claims: claims,
_jwtSettings.Issuer,
_jwtSettings.Audience,
claims,
expires: expiresAt,
signingCredentials: credentials
);
return new AuthResponse(
Token: new JwtSecurityTokenHandler().WriteToken(token),
ExpiresAt: expiresAt,
User: new UserInfo(user.Id, user.Email, user.VerifiedAt.HasValue)
new JwtSecurityTokenHandler().WriteToken(token),
expiresAt,
new UserInfo(user.Id, user.Email, user.VerifiedAt.HasValue)
);
}
}
}

View File

@@ -1,13 +1,13 @@
using System.Security.Claims;
using System.Security.Cryptography;
using api.Data;
using api.Features.Auth.Common;
using api.Features.Email.Services;
using api.Models;
using FastEndpoints;
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
{
@@ -29,7 +29,8 @@ public class ResendVerificationEndpoint(AppDbContext db, IEmailService emailServ
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;
}
@@ -60,4 +61,4 @@ public class ResendVerificationEndpoint(AppDbContext db, IEmailService emailServ
await HttpContext.Response.SendAsync(new MessageResponse("Verification email sent"), cancellation: ct);
}
}
}

View File

@@ -1,10 +1,10 @@
using api.Data;
using api.Features.Auth.Common;
using FastEndpoints;
using FluentValidation;
using Microsoft.EntityFrameworkCore;
using TrackQrApi.Data;
using TrackQrApi.Features.Auth.Common;
namespace api.Features.Auth.Endpoints;
namespace TrackQrApi.Features.Auth.Endpoints;
public class ResetPasswordRequest
{
@@ -82,7 +82,6 @@ public class ResetPasswordEndpoint(AppDbContext db)
await HttpContext.Response.SendAsync(
new MessageResponse("Password has been reset successfully"),
200,
cancellation: ct);
}
}
}

View File

@@ -1,11 +1,11 @@
using System.Security.Claims;
using api.Data;
using api.Features.Auth.Common;
using FastEndpoints;
using FluentValidation;
using Microsoft.EntityFrameworkCore;
using TrackQrApi.Data;
using TrackQrApi.Features.Auth.Common;
namespace api.Features.Auth.Endpoints;
namespace TrackQrApi.Features.Auth.Endpoints;
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);
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;
}
@@ -65,4 +66,4 @@ public class UpdateProfileEndpoint(AppDbContext db) : Endpoint<UpdateProfileRequ
await HttpContext.Response.SendAsync(response, cancellation: ct);
}
}
}

View File

@@ -1,10 +1,10 @@
using api.Data;
using api.Features.Auth.Common;
using FastEndpoints;
using FluentValidation;
using Microsoft.EntityFrameworkCore;
using TrackQrApi.Data;
using TrackQrApi.Features.Auth.Common;
namespace api.Features.Auth.Endpoints;
namespace TrackQrApi.Features.Auth.Endpoints;
public class VerifyEmailRequest
{
@@ -35,7 +35,8 @@ public class VerifyEmailEndpoint(AppDbContext db) : Endpoint<VerifyEmailRequest>
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;
}
@@ -43,7 +44,8 @@ public class VerifyEmailEndpoint(AppDbContext db) : Endpoint<VerifyEmailRequest>
{
db.EmailVerificationTokens.Remove(token);
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;
}
@@ -56,4 +58,4 @@ public class VerifyEmailEndpoint(AppDbContext db) : Endpoint<VerifyEmailRequest>
await HttpContext.Response.SendAsync(new MessageResponse("Email verified successfully"), cancellation: ct);
}
}
}

View File

@@ -1,4 +1,4 @@
namespace api.Features.Auth.Settings;
namespace TrackQrApi.Features.Auth.Settings;
public class JwtSettings
{
@@ -6,4 +6,4 @@ public class JwtSettings
public required string Issuer { get; set; }
public required string Audience { get; set; }
public int ExpirationMinutes { get; set; } = 60;
}
}

View File

@@ -1,4 +1,4 @@
namespace api.Features.Billing.Common;
namespace TrackQrApi.Features.Billing.Common;
public record CheckoutSessionRequest(
Guid WorkspaceId,
@@ -20,4 +20,4 @@ public record SubscriptionResponse(
DateTime? CurrentPeriodEnd,
bool IsActive,
bool CancelAtPeriodEnd
);
);

View File

@@ -1,14 +1,14 @@
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 FluentValidation;
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>
{
@@ -56,7 +56,8 @@ public class CreateCheckoutSessionEndpoint(AppDbContext db, IStripeService strip
if (!string.IsNullOrEmpty(workspace.StripeSubscriptionId))
{
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,
cancellation: ct);
return;
@@ -84,4 +85,4 @@ public class CreateCheckoutSessionEndpoint(AppDbContext db, IStripeService strip
cancellation: ct);
}
}
}
}

View File

@@ -1,11 +1,11 @@
using System.Security.Claims;
using api.Features.Auth.Common;
using api.Features.Billing.Common;
using api.Features.Billing.Services;
using FastEndpoints;
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>
{
@@ -57,4 +57,4 @@ public class CreatePortalSessionEndpoint(IStripeService stripeService)
cancellation: ct);
}
}
}
}

View File

@@ -1,12 +1,13 @@
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 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
{
@@ -34,7 +35,7 @@ public class GetSubscriptionEndpoint(AppDbContext db, IStripeService stripeServi
return;
}
var isActive = workspace.Plan != Models.WorkspacePlan.Free;
var isActive = workspace.Plan != WorkspacePlan.Free;
var cancelAtPeriodEnd = false;
// Get live subscription status from Stripe if exists
@@ -59,4 +60,4 @@ public class GetSubscriptionEndpoint(AppDbContext db, IStripeService stripeServi
await HttpContext.Response.SendAsync(response, cancellation: ct);
}
}
}

View File

@@ -1,10 +1,11 @@
using api.Features.Billing.Services;
using api.Features.Billing.Settings;
using FastEndpoints;
using Microsoft.Extensions.Options;
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(
IStripeService stripeService,
@@ -38,27 +39,20 @@ public class StripeWebhookEndpoint(
switch (stripeEvent.Type)
{
case "checkout.session.completed":
var session = stripeEvent.Data.Object as Stripe.Checkout.Session;
if (session != null)
{
await stripeService.HandleCheckoutCompletedAsync(session, ct);
}
var session = stripeEvent.Data.Object as Session;
if (session != null) await stripeService.HandleCheckoutCompletedAsync(session, ct);
break;
case "customer.subscription.updated":
var updatedSubscription = stripeEvent.Data.Object as Subscription;
if (updatedSubscription != null)
{
await stripeService.HandleSubscriptionUpdatedAsync(updatedSubscription, ct);
}
break;
case "customer.subscription.deleted":
var deletedSubscription = stripeEvent.Data.Object as Subscription;
if (deletedSubscription != null)
{
await stripeService.HandleSubscriptionDeletedAsync(deletedSubscription, ct);
}
break;
case "invoice.payment_failed":
@@ -76,7 +70,8 @@ public class StripeWebhookEndpoint(
catch (StripeException ex)
{
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)
{
@@ -84,4 +79,4 @@ public class StripeWebhookEndpoint(
await HttpContext.Response.SendAsync(new { error = "Webhook processing failed" }, 500, cancellation: ct);
}
}
}
}

View File

@@ -1,16 +1,18 @@
using api.Data;
using api.Features.Billing.Settings;
using api.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Stripe;
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
{
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<Subscription?> GetSubscriptionAsync(string subscriptionId, CancellationToken ct = default);
Task CancelSubscriptionAsync(string subscriptionId, CancellationToken ct = default);
@@ -23,9 +25,9 @@ public interface IStripeService
public class StripeService : IStripeService
{
private readonly ILogger<StripeService> _logger;
private readonly IServiceScopeFactory _scopeFactory;
private readonly StripeSettings _settings;
private readonly ILogger<StripeService> _logger;
public StripeService(
IServiceScopeFactory scopeFactory,
@@ -51,7 +53,7 @@ public class StripeService : IStripeService
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
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
var customerId = user.StripeCustomerId;
@@ -73,10 +75,7 @@ public class StripeService : IStripeService
}
var priceId = GetPriceIdForPlan(plan);
if (string.IsNullOrEmpty(priceId))
{
throw new InvalidOperationException($"No price configured for plan: {plan}");
}
if (string.IsNullOrEmpty(priceId)) throw new InvalidOperationException($"No price configured for plan: {plan}");
var sessionService = new SessionService();
var session = await sessionService.CreateAsync(new SessionCreateOptions
@@ -122,12 +121,10 @@ public class StripeService : IStripeService
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
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))
{
throw new InvalidOperationException("User has no Stripe customer");
}
var sessionService = new Stripe.BillingPortal.SessionService();
var session = await sessionService.CreateAsync(new Stripe.BillingPortal.SessionCreateOptions
@@ -202,10 +199,7 @@ public class StripeService : IStripeService
if (!string.IsNullOrEmpty(session.SubscriptionId))
{
var subscription = await GetSubscriptionAsync(session.SubscriptionId, ct);
if (subscription != null)
{
workspace.SubscriptionEndsAt = subscription.CurrentPeriodEnd;
}
if (subscription != null) workspace.SubscriptionEndsAt = subscription.CurrentPeriodEnd;
}
await db.SaveChangesAsync(ct);
@@ -247,11 +241,9 @@ public class StripeService : IStripeService
// Handle cancellation at period end
if (subscription.CancelAtPeriodEnd)
{
_logger.LogInformation(
"Workspace {WorkspaceId} subscription will cancel at {EndDate}",
workspace.Id, subscription.CurrentPeriodEnd);
}
await db.SaveChangesAsync(ct);
}
@@ -299,4 +291,4 @@ public class StripeService : IStripeService
return WorkspacePlan.Business;
return WorkspacePlan.Free;
}
}
}

View File

@@ -1,4 +1,4 @@
namespace api.Features.Billing.Settings;
namespace TrackQrApi.Features.Billing.Settings;
public class StripeSettings
{
@@ -6,4 +6,4 @@ public class StripeSettings
public string WebhookSecret { get; set; } = string.Empty;
public string ProPriceId { get; set; } = string.Empty;
public string BusinessPriceId { get; set; } = string.Empty;
}
}

View File

@@ -1,4 +1,4 @@
namespace api.Features.Domains.Common;
namespace TrackQrApi.Features.Domains.Common;
public record DomainResponse(
Guid Id,
@@ -20,4 +20,4 @@ public record DomainVerificationResponse(
bool IsVerified,
string Status,
string? Message
);
);

View File

@@ -1,15 +1,15 @@
using System.Security.Claims;
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 FluentValidation;
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
{
@@ -70,7 +70,8 @@ public class AddDomainEndpoint(AppDbContext db, IPlanLimitsService planLimits)
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;
}
@@ -113,4 +114,4 @@ public class AddDomainEndpoint(AppDbContext db, IPlanLimitsService planLimits)
{
return $"TXT _trakqr-verification {token}";
}
}
}

View File

@@ -1,10 +1,10 @@
using System.Security.Claims;
using api.Data;
using api.Features.Auth.Common;
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using TrackQrApi.Data;
using TrackQrApi.Features.Auth.Common;
namespace api.Features.Domains.Endpoints;
namespace TrackQrApi.Features.Domains.Endpoints;
public class DeleteDomainRequest
{
@@ -26,7 +26,8 @@ public class DeleteDomainEndpoint(AppDbContext db)
var domain = await db.Domains
.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)
{
@@ -50,6 +51,6 @@ public class DeleteDomainEndpoint(AppDbContext db)
db.Domains.Remove(domain);
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);
}
}
}

View File

@@ -1,11 +1,11 @@
using System.Security.Claims;
using api.Data;
using api.Features.Auth.Common;
using api.Features.Domains.Common;
using FastEndpoints;
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
{
@@ -45,6 +45,6 @@ public class GetDomainEndpoint(AppDbContext db)
domain.CreatedAt
);
await HttpContext.Response.SendAsync(response, 200, cancellation: ct);
await HttpContext.Response.SendAsync(response, cancellation: ct);
}
}
}

View File

@@ -1,11 +1,11 @@
using System.Security.Claims;
using api.Data;
using api.Features.Auth.Common;
using api.Features.Domains.Common;
using FastEndpoints;
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
{
@@ -48,6 +48,6 @@ public class ListDomainsEndpoint(AppDbContext db)
))
.ToListAsync(ct);
await HttpContext.Response.SendAsync(new DomainListResponse(domains), 200, cancellation: ct);
await HttpContext.Response.SendAsync(new DomainListResponse(domains), cancellation: ct);
}
}
}

View File

@@ -1,12 +1,12 @@
using System.Security.Claims;
using api.Data;
using api.Features.Auth.Common;
using api.Features.Domains.Common;
using api.Models;
using FastEndpoints;
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
{
@@ -28,7 +28,8 @@ public class VerifyDomainEndpoint(AppDbContext db)
var domain = await db.Domains
.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)
{
@@ -46,7 +47,7 @@ public class VerifyDomainEndpoint(AppDbContext db)
domain.Status.ToString(),
"Domain is already verified"
);
await HttpContext.Response.SendAsync(alreadyResponse, 200, cancellation: ct);
await HttpContext.Response.SendAsync(alreadyResponse, cancellation: ct);
return;
}
@@ -65,7 +66,7 @@ public class VerifyDomainEndpoint(AppDbContext db)
domain.Status.ToString(),
"Domain verified successfully"
);
await HttpContext.Response.SendAsync(successResponse, 200, cancellation: ct);
await HttpContext.Response.SendAsync(successResponse, cancellation: ct);
}
else
{
@@ -76,7 +77,7 @@ public class VerifyDomainEndpoint(AppDbContext db)
domain.Status.ToString(),
$"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);
}
}
@@ -88,4 +89,4 @@ public class VerifyDomainEndpoint(AppDbContext db)
var isVerified = hostname.StartsWith("verified-");
return Task.FromResult(isVerified);
}
}
}

View File

@@ -1,22 +1,18 @@
using api.Features.Email.Templates;
using Microsoft.Extensions.Options;
using TrackQrApi.Features.Email.Templates;
namespace api.Features.Email.Services;
namespace TrackQrApi.Features.Email.Services;
/// <summary>
/// Development email service that logs emails to console instead of sending them.
/// Useful for testing without a real SMTP server.
/// Development email service that logs emails to console instead of sending them.
/// Useful for testing without a real SMTP server.
/// </summary>
public class ConsoleEmailService : IEmailService
public class ConsoleEmailService(
IOptions<EmailSettings> settings,
ILogger<ConsoleEmailService> logger)
: IEmailService
{
private readonly EmailSettings _settings;
private readonly ILogger<ConsoleEmailService> _logger;
public ConsoleEmailService(IOptions<EmailSettings> settings, ILogger<ConsoleEmailService> logger)
{
_settings = settings.Value;
_logger = logger;
}
private readonly EmailSettings _settings = settings.Value;
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)
{
_logger.LogInformation($"""
logger.LogInformation($"""
EMAIL (Console Mode)
To: {toEmail}
Subject: {subject}
Action URL: {actionUrl}
""");
EMAIL (Console Mode)
To: {toEmail}
Subject: {subject}
Action URL: {actionUrl}
""");
}
}
}

View File

@@ -1,4 +1,4 @@
namespace api.Features.Email.Services;
namespace TrackQrApi.Features.Email.Services;
public interface IEmailService
{
@@ -33,4 +33,4 @@ public class SmtpSettings
public class SendGridSettings
{
public string ApiKey { get; set; } = string.Empty;
}
}

View File

@@ -1,14 +1,14 @@
using System.Net;
using System.Net.Mail;
using api.Features.Email.Templates;
using Microsoft.Extensions.Options;
using TrackQrApi.Features.Email.Templates;
namespace api.Features.Email.Services;
namespace TrackQrApi.Features.Email.Services;
public class SmtpEmailService : IEmailService
{
private readonly EmailSettings _settings;
private readonly ILogger<SmtpEmailService> _logger;
private readonly EmailSettings _settings;
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);
}
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 (subject, htmlBody, textBody) = EmailTemplates.EmailVerification(verifyUrl);
@@ -43,7 +44,8 @@ public class SmtpEmailService : IEmailService
_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)
{
@@ -76,9 +78,7 @@ public class SmtpEmailService : IEmailService
};
if (!string.IsNullOrEmpty(_settings.Smtp.Username))
{
client.Credentials = new NetworkCredential(_settings.Smtp.Username, _settings.Smtp.Password);
}
await client.SendMailAsync(message, ct);
_logger.LogDebug("Email sent successfully to {Email}", toEmail);
@@ -89,4 +89,4 @@ public class SmtpEmailService : IEmailService
throw;
}
}
}
}

View File

@@ -1,4 +1,4 @@
namespace api.Features.Email.Templates;
namespace TrackQrApi.Features.Email.Templates;
public static class EmailTemplates
{
@@ -218,4 +218,4 @@ Go to your dashboard: {dashboardUrl}
return (subject, htmlBody, textBody);
}
}
}

View File

@@ -1,11 +1,11 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
using api.Data;
using api.Models;
using Microsoft.EntityFrameworkCore;
using TrackQrApi.Data;
using TrackQrApi.Models;
namespace api.Features.Events.Services;
namespace TrackQrApi.Features.Events.Services;
public interface IEventTrackingService
{
@@ -13,7 +13,10 @@ public interface IEventTrackingService
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
{
// 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)
{
// Fire and forget - don't block the redirect
// Extract request data before the HttpContext is disposed
var requestData = CaptureRequestData(context);
_ = Task.Run(async () =>
{
try
{
await TrackEventInternalAsync(workspaceId, shortLinkId, null, EventType.Click, context);
await TrackEventInternalAsync(workspaceId, shortLinkId, null, EventType.Click, requestData);
}
catch (Exception ex)
{
@@ -39,12 +44,13 @@ public class EventTrackingService(IServiceScopeFactory scopeFactory, IGeoIpServi
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 () =>
{
try
{
await TrackEventInternalAsync(workspaceId, shortLinkId, qrCodeId, EventType.Scan, context);
await TrackEventInternalAsync(workspaceId, shortLinkId, qrCodeId, EventType.Scan, requestData);
}
catch (Exception ex)
{
@@ -55,20 +61,25 @@ public class EventTrackingService(IServiceScopeFactory scopeFactory, IGeoIpServi
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(
Guid workspaceId,
Guid shortLinkId,
Guid? qrCodeId,
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();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var ipAddress = GetClientIpAddress(context);
var userAgent = context.Request.Headers.UserAgent.ToString();
var referrer = context.Request.Headers.Referer.ToString();
var ipAddress = requestData.IpAddress;
var userAgent = requestData.UserAgent;
var referrer = requestData.Referrer;
var ipHash = HashIpAddress(ipAddress);
var deviceType = ParseDeviceType(userAgent);
@@ -112,10 +123,8 @@ public class EventTrackingService(IServiceScopeFactory scopeFactory, IGeoIpServi
// Check for forwarded headers (when behind a proxy/load balancer)
var forwardedFor = context.Request.Headers["X-Forwarded-For"].FirstOrDefault();
if (!string.IsNullOrEmpty(forwardedFor))
{
// Take the first IP in the chain (client IP)
return forwardedFor.Split(',')[0].Trim();
}
return context.Connection.RemoteIpAddress?.ToString() ?? "unknown";
}
@@ -164,4 +173,6 @@ public class EventTrackingService(IServiceScopeFactory scopeFactory, IGeoIpServi
return value.Length <= maxLength ? value : value[..maxLength];
}
private sealed record RequestData(string IpAddress, string UserAgent, string Referrer);
}

View File

@@ -1,7 +1,7 @@
using System.Net;
using MaxMind.GeoIP2;
namespace api.Features.Events.Services;
namespace TrackQrApi.Features.Events.Services;
public interface IGeoIpService
{
@@ -10,8 +10,8 @@ public interface IGeoIpService
public class GeoIpService : IGeoIpService, IDisposable
{
private readonly DatabaseReader? _reader;
private readonly ILogger<GeoIpService> _logger;
private readonly DatabaseReader? _reader;
public GeoIpService(IConfiguration configuration, ILogger<GeoIpService> logger)
{
@@ -19,7 +19,6 @@ public class GeoIpService : IGeoIpService, IDisposable
var dbPath = configuration["GeoIP:DatabasePath"];
if (!string.IsNullOrEmpty(dbPath) && File.Exists(dbPath))
{
try
{
_reader = new DatabaseReader(dbPath);
@@ -29,11 +28,13 @@ public class GeoIpService : IGeoIpService, IDisposable
{
_logger.LogWarning(ex, "Failed to load GeoIP database from {Path}", dbPath);
}
}
else
{
_logger.LogInformation("GeoIP database not configured or not found. Country detection disabled.");
}
}
public void Dispose()
{
_reader?.Dispose();
}
public string? GetCountryCode(string ipAddress)
@@ -44,20 +45,11 @@ public class GeoIpService : IGeoIpService, IDisposable
try
{
// Handle localhost and private IPs
if (ipAddress == "127.0.0.1" || ipAddress == "::1" || IsPrivateIp(ipAddress))
{
return null;
}
if (ipAddress == "127.0.0.1" || ipAddress == "::1" || IsPrivateIp(ipAddress)) return null;
if (!IPAddress.TryParse(ipAddress, out var ip))
{
return null;
}
if (!IPAddress.TryParse(ipAddress, out var ip)) return null;
if (_reader.TryCountry(ip, out var response))
{
return response?.Country?.IsoCode;
}
if (_reader.TryCountry(ip, out var response)) return response?.Country?.IsoCode;
}
catch (Exception ex)
{
@@ -87,9 +79,4 @@ public class GeoIpService : IGeoIpService, IDisposable
return false;
}
public void Dispose()
{
_reader?.Dispose();
}
}
}

View File

@@ -1,12 +1,12 @@
namespace api.Features.Links.Endpoints;
namespace TrackQrApi.Features.Links.Common;
public class LinkDto
{
public required Guid Id { get; set; }
public required Guid Id { get; set; }
public required string Slug { get; set; }
public required string DestinationUrl { get; set; }
public required string? Title { get; set; }
public required string Status { get; set; }
public int ClickCount { get; set; }
public DateTimeOffset CreatedAt { get; set; }
};
}

View File

@@ -1,4 +1,4 @@
namespace api.Features.Links.Common;
namespace TrackQrApi.Features.Links.Common;
public record LinkResponse(
Guid Id,
@@ -18,4 +18,4 @@ public record LinkResponse(
public record LinkListResponse(
IEnumerable<LinkResponse> Links
);
);

View File

@@ -1,6 +1,6 @@
using System.Security.Cryptography;
namespace api.Features.Links.Common;
namespace TrackQrApi.Features.Links.Common;
public static class SlugGenerator
{
@@ -11,4 +11,4 @@ public static class SlugGenerator
{
return RandomNumberGenerator.GetString(Chars, length);
}
}
}

View File

@@ -1,12 +1,12 @@
using System.Security.Claims;
using api.Data;
using api.Features.Auth.Common;
using api.Features.Links.Common;
using api.Models;
using FastEndpoints;
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
{
@@ -59,7 +59,8 @@ public class BulkCreateLinksEndpoint(AppDbContext db)
// Limit bulk creation to 100 links at a time
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;
}
@@ -70,7 +71,7 @@ public class BulkCreateLinksEndpoint(AppDbContext db)
var currentLinkCount = await db.ShortLinks.CountAsync(l => l.WorkspaceId == req.WorkspaceId, ct);
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];
@@ -130,7 +131,7 @@ public class BulkCreateLinksEndpoint(AppDbContext db)
Title = item.Title,
Status = ShortLinkStatus.Active,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow
};
db.ShortLinks.Add(link);
@@ -143,7 +144,7 @@ public class BulkCreateLinksEndpoint(AppDbContext db)
Title = link?.Title,
Status = link.Status.ToString(),
ClickCount = 0,
CreatedAt = link.CreatedAt,
CreatedAt = link.CreatedAt
});
}
@@ -174,4 +175,4 @@ public class BulkCreateLinksEndpoint(AppDbContext db)
_ => 100 // Free plan
};
}
}
}

View File

@@ -1,14 +1,14 @@
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 FluentValidation;
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
{
@@ -30,7 +30,8 @@ public class CreateLinkValidator : Validator<CreateLinkRequest>
RuleFor(x => x.Slug)
.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));
RuleFor(x => x.Title)
@@ -107,7 +108,8 @@ public class CreateLinkEndpoint(AppDbContext db, IPlanLimitsService planLimits)
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;
}
}
@@ -147,4 +149,4 @@ public class CreateLinkEndpoint(AppDbContext db, IPlanLimitsService planLimits)
await HttpContext.Response.SendAsync(response, 201, cancellation: ct);
}
}
}

View File

@@ -1,10 +1,10 @@
using System.Security.Claims;
using api.Data;
using api.Features.Auth.Common;
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using TrackQrApi.Data;
using TrackQrApi.Features.Auth.Common;
namespace api.Features.Links.Endpoints;
namespace TrackQrApi.Features.Links.Endpoints;
public class DeleteLinkRequest
{
@@ -42,6 +42,6 @@ public class DeleteLinkEndpoint(AppDbContext db)
link.DeletedAt = DateTime.UtcNow;
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);
}
}
}

View File

@@ -1,11 +1,11 @@
using System.Security.Claims;
using api.Data;
using api.Features.Auth.Common;
using api.Features.Links.Common;
using FastEndpoints;
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
{
@@ -26,7 +26,8 @@ public class GetLinkEndpoint(AppDbContext db)
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
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(
l.Id,
l.WorkspaceId,
@@ -50,6 +51,6 @@ public class GetLinkEndpoint(AppDbContext db)
return;
}
await HttpContext.Response.SendAsync(link, 200, cancellation: ct);
await HttpContext.Response.SendAsync(link, cancellation: ct);
}
}
}

View File

@@ -1,11 +1,12 @@
using System.Security.Claims;
using api.Data;
using api.Features.Auth.Common;
using api.Features.Links.Common;
using FastEndpoints;
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
{
@@ -41,22 +42,14 @@ public class ListLinksEndpoint(AppDbContext db)
.Where(l => l.WorkspaceId == req.WorkspaceId);
// Filter by deleted status (exclude soft-deleted by default)
if (!req.IncludeDeleted)
{
query = query.Where(l => l.DeletedAt == null);
}
if (!req.IncludeDeleted) query = query.Where(l => l.DeletedAt == null);
// Filter by project if specified
if (req.ProjectId.HasValue)
{
query = query.Where(l => l.ProjectId == req.ProjectId.Value);
}
if (req.ProjectId.HasValue) query = query.Where(l => l.ProjectId == req.ProjectId.Value);
// 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);
}
var links = await query
.OrderByDescending(l => l.CreatedAt)
@@ -77,6 +70,6 @@ public class ListLinksEndpoint(AppDbContext db)
))
.ToListAsync(ct);
await HttpContext.Response.SendAsync(new LinkListResponse(links), 200, cancellation: ct);
await HttpContext.Response.SendAsync(new LinkListResponse(links), cancellation: ct);
}
}
}

View File

@@ -1,11 +1,11 @@
using System.Security.Claims;
using api.Data;
using api.Features.Auth.Common;
using api.Features.Links.Common;
using FastEndpoints;
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
{
@@ -60,6 +60,6 @@ public class RestoreLinkEndpoint(AppDbContext db)
link.DeletedAt
);
await HttpContext.Response.SendAsync(response, 200, cancellation: ct);
await HttpContext.Response.SendAsync(response, cancellation: ct);
}
}
}

View File

@@ -1,13 +1,13 @@
using System.Security.Claims;
using api.Data;
using api.Features.Auth.Common;
using api.Features.Links.Common;
using api.Models;
using FastEndpoints;
using FluentValidation;
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
{
@@ -66,7 +66,8 @@ public class UpdateLinkEndpoint(AppDbContext db)
var link = await db.ShortLinks
.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)
{
@@ -88,43 +89,22 @@ public class UpdateLinkEndpoint(AppDbContext db)
}
// Update fields
if (!string.IsNullOrEmpty(req.DestinationUrl))
{
link.DestinationUrl = req.DestinationUrl;
}
if (!string.IsNullOrEmpty(req.DestinationUrl)) link.DestinationUrl = req.DestinationUrl;
if (req.Title != null)
{
link.Title = req.Title;
}
if (req.Title != null) link.Title = req.Title;
if (!string.IsNullOrEmpty(req.Status) && Enum.TryParse<ShortLinkStatus>(req.Status, true, out var status))
{
link.Status = status;
}
if (req.ExpiresAt.HasValue)
{
link.ExpiresAt = req.ExpiresAt.Value;
}
if (req.ExpiresAt.HasValue) link.ExpiresAt = req.ExpiresAt.Value;
if (!string.IsNullOrEmpty(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)
{
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;
await db.SaveChangesAsync(ct);
@@ -144,6 +124,6 @@ public class UpdateLinkEndpoint(AppDbContext db)
link.UpdatedAt
);
await HttpContext.Response.SendAsync(response, 200, cancellation: ct);
await HttpContext.Response.SendAsync(response, cancellation: ct);
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -1,8 +1,8 @@
using api.Data;
using api.Models;
using Microsoft.EntityFrameworkCore;
using TrackQrApi.Data;
using TrackQrApi.Models;
namespace api.Features.Plans.Services;
namespace TrackQrApi.Features.Plans.Services;
public interface IPlanLimitsService
{
@@ -51,38 +51,41 @@ public class PlanLimitsService(IServiceScopeFactory scopeFactory) : IPlanLimitsS
private static readonly Dictionary<WorkspacePlan, PlanLimits> PlanConfigs = new()
{
[WorkspacePlan.Free] = new PlanLimits(
MaxWorkspaces: 1,
MaxLinksPerWorkspace: 50,
MaxQRCodesPerWorkspace: 25,
MaxDomainsPerWorkspace: 0,
MaxEventsPerMonth: 10_000,
HasCustomDomains: false,
HasPasswordProtection: false,
HasAnalytics: true
1,
50,
25,
0,
10_000,
false,
false,
true
),
[WorkspacePlan.Pro] = new PlanLimits(
MaxWorkspaces: 5,
MaxLinksPerWorkspace: 5_000,
MaxQRCodesPerWorkspace: 1_000,
MaxDomainsPerWorkspace: 3,
MaxEventsPerMonth: 100_000,
HasCustomDomains: true,
HasPasswordProtection: true,
HasAnalytics: true
5,
5_000,
1_000,
3,
100_000,
true,
true,
true
),
[WorkspacePlan.Business] = new PlanLimits(
MaxWorkspaces: int.MaxValue,
MaxLinksPerWorkspace: int.MaxValue,
MaxQRCodesPerWorkspace: int.MaxValue,
MaxDomainsPerWorkspace: int.MaxValue,
MaxEventsPerMonth: int.MaxValue,
HasCustomDomains: true,
HasPasswordProtection: true,
HasAnalytics: true
int.MaxValue,
int.MaxValue,
int.MaxValue,
int.MaxValue,
int.MaxValue,
true,
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)
{
@@ -114,12 +117,12 @@ public class PlanLimitsService(IServiceScopeFactory scopeFactory) : IPlanLimitsS
: WorkspacePlan.Free;
return new UsageStats(
TotalWorkspaces: workspaces.Count,
TotalLinks: totalLinks,
TotalQRCodes: totalQRCodes,
TotalDomains: totalDomains,
EventsThisMonth: eventsThisMonth,
HighestPlan: highestPlan
workspaces.Count,
totalLinks,
totalQRCodes,
totalDomains,
eventsThisMonth,
highestPlan
);
}
@@ -147,13 +150,13 @@ public class PlanLimitsService(IServiceScopeFactory scopeFactory) : IPlanLimitsS
var limits = GetLimits(workspace.Plan);
return new WorkspaceUsageStats(
WorkspaceId: workspaceId,
Plan: workspace.Plan,
Links: links,
QRCodes: qrCodes,
Domains: domains,
EventsThisMonth: eventsThisMonth,
Limits: limits
workspaceId,
workspace.Plan,
links,
qrCodes,
domains,
eventsThisMonth,
limits
);
}
@@ -187,4 +190,4 @@ public class PlanLimitsService(IServiceScopeFactory scopeFactory) : IPlanLimitsS
var usage = await GetWorkspaceUsageAsync(workspaceId, ct);
return usage.EventsThisMonth < usage.Limits.MaxEventsPerMonth;
}
}
}

View File

@@ -1,4 +1,4 @@
namespace api.Features.Projects.Common;
namespace TrackQrApi.Features.Projects.Common;
public record ProjectResponse(
Guid Id,
@@ -12,4 +12,4 @@ public record ProjectResponse(
public record ProjectListResponse(
IEnumerable<ProjectResponse> Projects
);
);

View File

@@ -1,13 +1,13 @@
using System.Security.Claims;
using api.Data;
using api.Features.Auth.Common;
using api.Features.Projects.Common;
using api.Models;
using FastEndpoints;
using FluentValidation;
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
{
@@ -72,4 +72,4 @@ public class CreateProjectEndpoint(AppDbContext db)
await HttpContext.Response.SendAsync(response, 201, cancellation: ct);
}
}
}

View File

@@ -1,10 +1,10 @@
using System.Security.Claims;
using api.Data;
using api.Features.Auth.Common;
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using TrackQrApi.Data;
using TrackQrApi.Features.Auth.Common;
namespace api.Features.Projects.Endpoints;
namespace TrackQrApi.Features.Projects.Endpoints;
public class DeleteProjectRequest
{
@@ -26,7 +26,8 @@ public class DeleteProjectEndpoint(AppDbContext db)
var project = await db.Projects
.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)
{
@@ -37,6 +38,6 @@ public class DeleteProjectEndpoint(AppDbContext db)
db.Projects.Remove(project);
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);
}
}
}

View File

@@ -1,11 +1,11 @@
using System.Security.Claims;
using api.Data;
using api.Features.Auth.Common;
using api.Features.Projects.Common;
using FastEndpoints;
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
{
@@ -44,6 +44,6 @@ public class GetProjectEndpoint(AppDbContext db)
return;
}
await HttpContext.Response.SendAsync(project, 200, cancellation: ct);
await HttpContext.Response.SendAsync(project, cancellation: ct);
}
}
}

View File

@@ -1,11 +1,11 @@
using System.Security.Claims;
using api.Data;
using api.Features.Auth.Common;
using api.Features.Projects.Common;
using FastEndpoints;
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
{
@@ -48,6 +48,6 @@ public class ListProjectsEndpoint(AppDbContext db)
))
.ToListAsync(ct);
await HttpContext.Response.SendAsync(new ProjectListResponse(projects), 200, cancellation: ct);
await HttpContext.Response.SendAsync(new ProjectListResponse(projects), cancellation: ct);
}
}
}

View File

@@ -1,12 +1,12 @@
using System.Security.Claims;
using api.Data;
using api.Features.Auth.Common;
using api.Features.Projects.Common;
using FastEndpoints;
using FluentValidation;
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
{
@@ -42,7 +42,8 @@ public class UpdateProjectEndpoint(AppDbContext db)
.Include(p => p.Workspace)
.Include(p => p.ShortLinks)
.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)
{
@@ -64,6 +65,6 @@ public class UpdateProjectEndpoint(AppDbContext db)
project.CreatedAt
);
await HttpContext.Response.SendAsync(response, 200, cancellation: ct);
await HttpContext.Response.SendAsync(response, cancellation: ct);
}
}
}

View File

@@ -1,9 +1,7 @@
using System.Text.Json.Serialization;
namespace api.Features.QRCodes.Common;
namespace TrackQrApi.Features.QRCodes.Common;
/// <summary>
/// QR code style configuration stored as JSON
/// QR code style configuration stored as JSON
/// </summary>
public class QRCodeStyle
{
@@ -52,4 +50,4 @@ public record QRCodePreviewResponse(
string Format,
int Width,
int Height
);
);

View File

@@ -1,15 +1,15 @@
using System.Security.Claims;
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 FluentValidation;
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
{
@@ -73,9 +73,11 @@ public class CreateQRCodeEndpoint(AppDbContext db, IPlanLimitsService planLimits
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;
}
linkSlug = link.Slug;
}
@@ -103,7 +105,8 @@ public class CreateQRCodeEndpoint(AppDbContext db, IPlanLimitsService planLimits
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;
}
@@ -146,4 +149,4 @@ public class CreateQRCodeEndpoint(AppDbContext db, IPlanLimitsService planLimits
await HttpContext.Response.SendAsync(response, 201, cancellation: ct);
}
}
}

View File

@@ -1,10 +1,10 @@
using System.Security.Claims;
using api.Data;
using api.Features.Auth.Common;
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using TrackQrApi.Data;
using TrackQrApi.Features.Auth.Common;
namespace api.Features.QRCodes.Endpoints;
namespace TrackQrApi.Features.QRCodes.Endpoints;
public class DeleteQRCodeRequest
{
@@ -26,7 +26,8 @@ public class DeleteQRCodeEndpoint(AppDbContext db)
var qrCode = await db.QrCodeDesigns
.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)
{
@@ -37,6 +38,6 @@ public class DeleteQRCodeEndpoint(AppDbContext db)
db.QrCodeDesigns.Remove(qrCode);
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);
}
}
}

View File

@@ -1,14 +1,14 @@
using System.Security.Claims;
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 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
{
@@ -18,7 +18,10 @@ public class ExportQRCodeRequest
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>
{
public override void Configure()
@@ -44,7 +47,8 @@ public class ExportQRCodeEndpoint(AppDbContext db, IQrCodeGeneratorService qrGen
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;
}
@@ -63,10 +67,7 @@ public class ExportQRCodeEndpoint(AppDbContext db, IQrCodeGeneratorService qrGen
if (qrCode.LogoAsset != null)
{
var logoResult = await assetStorage.GetAsync(qrCode.LogoAsset.StorageKey);
if (logoResult.HasValue)
{
logoStream = logoResult.Value.Stream;
}
if (logoResult.HasValue) logoStream = logoResult.Value.Stream;
}
try
@@ -92,4 +93,4 @@ public class ExportQRCodeEndpoint(AppDbContext db, IQrCodeGeneratorService qrGen
logoStream?.Dispose();
}
}
}
}

View File

@@ -1,11 +1,11 @@
using System.Security.Claims;
using api.Data;
using api.Features.Auth.Common;
using api.Models;
using FastEndpoints;
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
{
@@ -65,16 +65,16 @@ public class GetQRCodeAnalyticsEndpoint(AppDbContext db)
.ToListAsync(ct);
var summary = new QRCodeAnalyticsSummary(
TotalScans: events.Count,
UniqueVisitors: events.Select(e => e.IpHash).Distinct().Count()
events.Count,
events.Select(e => e.IpHash).Distinct().Count()
);
var timeSeries = events
.GroupBy(e => e.Timestamp.Date)
.OrderBy(g => g.Key)
.Select(g => new QRCodeTimeSeriesPoint(
Date: g.Key.ToString("yyyy-MM-dd"),
Scans: g.Count()
g.Key.ToString("yyyy-MM-dd"),
g.Count()
))
.ToList();
@@ -98,14 +98,14 @@ public class GetQRCodeAnalyticsEndpoint(AppDbContext db)
.ToDictionary(g => g.Key, g => g.Count());
var response = new QRCodeAnalyticsResponse(
QRCodeId: qrCode.Id,
Name: qrCode.Name,
LinkSlug: qrCode.ShortLink?.Slug,
Summary: summary,
TimeSeries: timeSeries,
DeviceBreakdown: deviceBreakdown,
ReferrerBreakdown: referrerBreakdown,
CountryBreakdown: countryBreakdown
qrCode.Id,
qrCode.Name,
qrCode.ShortLink?.Slug,
summary,
timeSeries,
deviceBreakdown,
referrerBreakdown,
countryBreakdown
);
await HttpContext.Response.SendAsync(response, cancellation: ct);
@@ -135,4 +135,4 @@ public class GetQRCodeAnalyticsEndpoint(AppDbContext db)
return referrer;
}
}
}
}

View File

@@ -1,12 +1,12 @@
using System.Security.Claims;
using System.Text.Json;
using api.Data;
using api.Features.Auth.Common;
using api.Features.QRCodes.Common;
using FastEndpoints;
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
{
@@ -55,6 +55,6 @@ public class GetQRCodeEndpoint(AppDbContext db)
qrCode.UpdatedAt
);
await HttpContext.Response.SendAsync(response, 200, cancellation: ct);
await HttpContext.Response.SendAsync(response, cancellation: ct);
}
}
}

View File

@@ -1,12 +1,12 @@
using System.Security.Claims;
using System.Text.Json;
using api.Data;
using api.Features.Auth.Common;
using api.Features.QRCodes.Common;
using FastEndpoints;
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
{
@@ -40,15 +40,9 @@ public class ListQRCodesEndpoint(AppDbContext db)
var query = db.QrCodeDesigns
.Where(q => q.WorkspaceId == req.WorkspaceId);
if (req.ProjectId.HasValue)
{
query = query.Where(q => q.ProjectId == req.ProjectId.Value);
}
if (req.ProjectId.HasValue) query = query.Where(q => q.ProjectId == req.ProjectId.Value);
if (req.ShortLinkId.HasValue)
{
query = query.Where(q => q.ShortLinkId == req.ShortLinkId.Value);
}
if (req.ShortLinkId.HasValue) query = query.Where(q => q.ShortLinkId == req.ShortLinkId.Value);
var qrCodes = await query
.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);
}
}
}

View File

@@ -1,14 +1,14 @@
using System.Security.Claims;
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 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
{
@@ -17,7 +17,10 @@ public class PreviewQRCodeRequest
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>
{
public override void Configure()
@@ -43,7 +46,8 @@ public class PreviewQRCodeEndpoint(AppDbContext db, IQrCodeGeneratorService qrGe
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;
}
@@ -60,10 +64,7 @@ public class PreviewQRCodeEndpoint(AppDbContext db, IQrCodeGeneratorService qrGe
if (qrCode.LogoAsset != null)
{
var logoResult = await assetStorage.GetAsync(qrCode.LogoAsset.StorageKey);
if (logoResult.HasValue)
{
logoStream = logoResult.Value.Stream;
}
if (logoResult.HasValue) logoStream = logoResult.Value.Stream;
}
try
@@ -71,17 +72,17 @@ public class PreviewQRCodeEndpoint(AppDbContext db, IQrCodeGeneratorService qrGe
var dataUrl = qrGenerator.GenerateDataUrl(linkUrl, style, size, logoStream);
var response = new QRCodePreviewResponse(
DataUrl: dataUrl,
Format: "png",
Width: size,
Height: size
dataUrl,
"png",
size,
size
);
await HttpContext.Response.SendAsync(response, 200, cancellation: ct);
await HttpContext.Response.SendAsync(response, cancellation: ct);
}
finally
{
logoStream?.Dispose();
}
}
}
}

View File

@@ -1,12 +1,12 @@
using System.Security.Claims;
using System.Text.Json;
using api.Data;
using api.Features.Auth.Common;
using api.Features.QRCodes.Common;
using FastEndpoints;
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
{
@@ -36,7 +36,8 @@ public class UpdateQRCodeEndpoint(AppDbContext db)
.Include(q => q.Workspace)
.Include(q => q.ShortLink)
.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)
{
@@ -55,6 +56,7 @@ public class UpdateQRCodeEndpoint(AppDbContext db)
await HttpContext.Response.SendAsync(new MessageResponse("Project not found"), 404, cancellation: ct);
return;
}
qrCode.ProjectId = req.ProjectId.Value;
}
else if (req.RemoveProject == true)
@@ -63,10 +65,7 @@ public class UpdateQRCodeEndpoint(AppDbContext db)
}
// Update name if provided
if (!string.IsNullOrWhiteSpace(req.Name))
{
qrCode.Name = req.Name;
}
if (!string.IsNullOrWhiteSpace(req.Name)) qrCode.Name = req.Name;
// Handle logo asset update
if (req.LogoAssetId.HasValue)
@@ -76,9 +75,11 @@ public class UpdateQRCodeEndpoint(AppDbContext db)
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;
}
qrCode.LogoAssetId = req.LogoAssetId.Value;
// Reload the asset for the response
qrCode.LogoAsset = await db.Assets.FindAsync([req.LogoAssetId.Value], ct);
@@ -89,10 +90,7 @@ public class UpdateQRCodeEndpoint(AppDbContext db)
qrCode.LogoAsset = null;
}
if (req.Style != null)
{
qrCode.StyleJson = JsonSerializer.Serialize(req.Style);
}
if (req.Style != null) qrCode.StyleJson = JsonSerializer.Serialize(req.Style);
qrCode.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync(ct);
@@ -114,6 +112,6 @@ public class UpdateQRCodeEndpoint(AppDbContext db)
qrCode.UpdatedAt
);
await HttpContext.Response.SendAsync(response, 200, cancellation: ct);
await HttpContext.Response.SendAsync(response, cancellation: ct);
}
}
}

View File

@@ -1,8 +1,9 @@
using api.Features.QRCodes.Common;
using System.Text;
using QRCoder;
using SkiaSharp;
using TrackQrApi.Features.QRCodes.Common;
namespace api.Features.QRCodes.Services;
namespace TrackQrApi.Features.QRCodes.Services;
public interface IQrCodeGeneratorService
{
@@ -23,7 +24,7 @@ public class QrCodeGeneratorService : IQrCodeGeneratorService
var moduleCount = moduleMatrix.Count;
// 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 actualSize = totalModules * pixelsPerModule;
@@ -47,29 +48,21 @@ public class QrCodeGeneratorService : IQrCodeGeneratorService
var quietZoneOffset = style.QuietZone * pixelsPerModule;
for (int y = 0; y < moduleCount; y++)
{
for (int x = 0; x < moduleCount; x++)
for (var y = 0; y < moduleCount; y++)
for (var 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)
var isEye = IsFinderPattern(x, y, moduleCount);
// Check if this is part of a finder pattern (eyes)
var isEye = IsFinderPattern(x, y, moduleCount);
if (isEye)
{
DrawModule(canvas, px, py, pixelsPerModule, modulePaint, style.EyeShape);
}
else
{
DrawModule(canvas, px, py, pixelsPerModule, modulePaint, style.ModuleShape);
}
}
if (isEye)
DrawModule(canvas, px, py, pixelsPerModule, modulePaint, style.EyeShape);
else
DrawModule(canvas, px, py, pixelsPerModule, modulePaint, style.ModuleShape);
}
}
// Encode to PNG
using var image = surface.Snapshot();
@@ -77,15 +70,84 @@ public class QrCodeGeneratorService : IQrCodeGeneratorService
var qrBytes = data.ToArray();
// If no logo, return the QR code as-is
if (logoStream == null)
{
return qrBytes;
}
if (logoStream == null) return qrBytes;
// Overlay logo on QR code
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)
{
// 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)
{
using var qrBitmap = SKBitmap.Decode(qrBytes);
using var logoBitmap = SKBitmap.Decode(logoStream);
if (qrBitmap == null || logoBitmap == null)
{
return qrBytes;
}
if (qrBitmap == null || logoBitmap == null) return qrBytes;
// Logo should be about 20% of QR code size
var logoSize = (int)(qrSize * 0.2);
@@ -232,12 +219,9 @@ public class QrCodeGeneratorService : IQrCodeGeneratorService
// Resize and draw logo
using var resizedLogo = logoBitmap.Resize(
new SKImageInfo(logoSize, logoSize),
new SKImageInfo(logoSize, logoSize),
new SKSamplingOptions(SKCubicResampler.Mitchell));
if (resizedLogo != null)
{
canvas.DrawBitmap(resizedLogo, logoX, logoY);
}
if (resizedLogo != null) canvas.DrawBitmap(resizedLogo, logoX, logoY);
// Encode to PNG
using var image = surface.Snapshot();
@@ -274,4 +258,4 @@ public class QrCodeGeneratorService : IQrCodeGeneratorService
// Default to black
return SKColors.Black;
}
}
}

View File

@@ -1,12 +1,12 @@
using api.Data;
using api.Features.Auth.Common;
using api.Features.Events.Services;
using api.Models;
using FastEndpoints;
using FluentValidation;
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
{
@@ -101,4 +101,4 @@ public class PasswordRedirectEndpoint(AppDbContext db, IEventTrackingService eve
HttpContext.Response.Headers.Location = link.DestinationUrl;
await HttpContext.Response.StartAsync(ct);
}
}
}

View File

@@ -1,11 +1,11 @@
using api.Data;
using api.Features.Auth.Common;
using api.Features.Events.Services;
using api.Models;
using FastEndpoints;
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
{
@@ -78,17 +78,13 @@ public class RedirectEndpoint(AppDbContext db, IEventTrackingService eventTracki
// Track event asynchronously (fire and forget)
// If qr parameter is present, track as scan; otherwise track as click
if (req.Qr.HasValue)
{
await eventTracking.TrackScanAsync(link.WorkspaceId, link.Id, req.Qr.Value, HttpContext);
}
else
{
await eventTracking.TrackClickAsync(link.WorkspaceId, link.Id, HttpContext);
}
// Redirect to destination (302 Found)
HttpContext.Response.StatusCode = StatusCodes.Status302Found;
HttpContext.Response.Headers.Location = link.DestinationUrl;
await HttpContext.Response.StartAsync(ct);
}
}
}

View File

@@ -1,4 +1,4 @@
namespace api.Features.Workspaces.Common;
namespace TrackQrApi.Features.Workspaces.Common;
public record WorkspaceResponse(
Guid Id,
@@ -9,4 +9,4 @@ public record WorkspaceResponse(
public record WorkspaceListResponse(
IEnumerable<WorkspaceResponse> Workspaces
);
);

View File

@@ -1,13 +1,13 @@
using System.Security.Claims;
using api.Data;
using api.Features.Auth.Common;
using api.Features.Plans.Services;
using api.Features.Workspaces.Common;
using api.Models;
using FastEndpoints;
using FluentValidation;
using TrackQrApi.Data;
using TrackQrApi.Features.Auth.Common;
using TrackQrApi.Features.Plans.Services;
using TrackQrApi.Features.Workspaces.Common;
using TrackQrApi.Models;
namespace api.Features.Workspaces.Endpoints;
namespace TrackQrApi.Features.Workspaces.Endpoints;
public class CreateWorkspaceRequest
{
@@ -67,4 +67,4 @@ public class CreateWorkspaceEndpoint(AppDbContext db, IPlanLimitsService planLim
await HttpContext.Response.SendAsync(response, 201, cancellation: ct);
}
}
}

View File

@@ -1,10 +1,10 @@
using System.Security.Claims;
using api.Data;
using api.Features.Auth.Common;
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using TrackQrApi.Data;
using TrackQrApi.Features.Auth.Common;
namespace api.Features.Workspaces.Endpoints;
namespace TrackQrApi.Features.Workspaces.Endpoints;
public class DeleteWorkspaceRequest
{
@@ -35,6 +35,6 @@ public class DeleteWorkspaceEndpoint(AppDbContext db)
db.Workspaces.Remove(workspace);
await db.SaveChangesAsync(ct);
await HttpContext.Response.SendAsync(new MessageResponse("Workspace deleted"), 200, cancellation: ct);
await HttpContext.Response.SendAsync(new MessageResponse("Workspace deleted"), cancellation: ct);
}
}
}

View File

@@ -1,11 +1,11 @@
using System.Security.Claims;
using api.Data;
using api.Features.Auth.Common;
using api.Features.Workspaces.Common;
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using TrackQrApi.Data;
using TrackQrApi.Features.Auth.Common;
using TrackQrApi.Features.Workspaces.Common;
namespace api.Features.Workspaces.Endpoints;
namespace TrackQrApi.Features.Workspaces.Endpoints;
public class GetWorkspaceRequest
{
@@ -40,6 +40,6 @@ public class GetWorkspaceEndpoint(AppDbContext db)
return;
}
await HttpContext.Response.SendAsync(workspace, 200, cancellation: ct);
await HttpContext.Response.SendAsync(workspace, cancellation: ct);
}
}
}

View File

@@ -1,10 +1,10 @@
using System.Security.Claims;
using api.Data;
using api.Features.Workspaces.Common;
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using TrackQrApi.Data;
using TrackQrApi.Features.Workspaces.Common;
namespace api.Features.Workspaces.Endpoints;
namespace TrackQrApi.Features.Workspaces.Endpoints;
public class ListWorkspacesEndpoint(AppDbContext db)
: EndpointWithoutRequest<WorkspaceListResponse>
@@ -29,6 +29,6 @@ public class ListWorkspacesEndpoint(AppDbContext db)
))
.ToListAsync(ct);
await HttpContext.Response.SendAsync(new WorkspaceListResponse(workspaces), 200, cancellation: ct);
await HttpContext.Response.SendAsync(new WorkspaceListResponse(workspaces), cancellation: ct);
}
}
}

View File

@@ -1,12 +1,12 @@
using System.Security.Claims;
using api.Data;
using api.Features.Auth.Common;
using api.Features.Workspaces.Common;
using FastEndpoints;
using FluentValidation;
using Microsoft.EntityFrameworkCore;
using TrackQrApi.Data;
using TrackQrApi.Features.Auth.Common;
using TrackQrApi.Features.Workspaces.Common;
namespace api.Features.Workspaces.Endpoints;
namespace TrackQrApi.Features.Workspaces.Endpoints;
public class UpdateWorkspaceRequest
{
@@ -55,6 +55,6 @@ public class UpdateWorkspaceEndpoint(AppDbContext db)
workspace.CreatedAt
);
await HttpContext.Response.SendAsync(response, 200, cancellation: ct);
await HttpContext.Response.SendAsync(response, cancellation: ct);
}
}
}

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