max / makenotwork
56 files changed,
+3361 insertions,
-988 deletions
| @@ -72,10 +72,12 @@ Everything listed here is live and working. | |||
| 72 | 72 | ||
| 73 | 73 | - **Source-available codebase**: PolyForm Noncommercial 1.0.0 | |
| 74 | 74 | - **Creator waitlist**: Invite-only launch with lottery waves and hand-picked approvals | |
| 75 | - | - **Admin CLI** (`mnw-admin`): Command-line tool for waitlist management, creator approval, spam flagging, wave execution, and stats -- connects directly to the database, no web UI needed | |
| 75 | + | - **Admin CLI** (`mnw-admin`): Command-line tool for waitlist management, creator approval, spam flagging, wave execution, stats, user suspension/unsuspension, appeal processing, revenue reports, transaction history, CSV data export, and S3 storage audits -- connects directly to the database, no web UI needed | |
| 76 | 76 | - **Documentation**: Server-rendered from markdown, auto-linked cross-references | |
| 77 | 77 | - **Health monitoring**: Real uptime tracking, database status, service connectivity checks | |
| 78 | - | - **386 automated tests**: Unit, integration, workflow, and health tests | |
| 78 | + | - **Malware scanning**: ClamAV + VirusTotal hash lookup on file uploads | |
| 79 | + | - **Creator guide**: 12-page documentation covering the full UX surface area | |
| 80 | + | - **619 automated tests**: Unit, integration, workflow, and health tests | |
| 79 | 81 | ||
| 80 | 82 | ### Developer Infrastructure (SyncKit) | |
| 81 | 83 | ||
| @@ -98,8 +100,7 @@ Near-term work. No timelines because we ship when it's ready. | |||
| 98 | 100 | - **Free trial support** for subscription tiers | |
| 99 | 101 | - **Sale and follower notifications** (email alerts for creators) | |
| 100 | 102 | - **Contacts dashboard** (view fans who shared their email at purchase) | |
| 101 | - | - **Malware scanning** on upload (ClamAV + VirusTotal hash lookup) | |
| 102 | - | - **Admin CLI expansion**: User suspension/unsuspension, appeal processing, broadcast sending, revenue/transaction reports, data export triggers, storage usage audit | |
| 103 | + | - **Admin CLI expansion**: Broadcast sending (deferred until Postmark integration) | |
| 103 | 104 | ||
| 104 | 105 | --- | |
| 105 | 106 |
| @@ -0,0 +1,91 @@ | |||
| 1 | + | # Getting Started | |
| 2 | + | ||
| 3 | + | Your first 15 minutes on Makenot.work — from sign-up to your first published item. | |
| 4 | + | ||
| 5 | + | --- | |
| 6 | + | ||
| 7 | + | ## Create Your Account | |
| 8 | + | ||
| 9 | + | 1. Visit the homepage and click **Join** | |
| 10 | + | 2. Pick a username (this becomes your public URL: `/u/yourname`) | |
| 11 | + | 3. Enter your email and a strong password | |
| 12 | + | 4. Verify your email (check your inbox) | |
| 13 | + | ||
| 14 | + | Your fan account is ready immediately. You can browse, follow creators, and purchase content right away. | |
| 15 | + | ||
| 16 | + | ## Apply for Creator Access | |
| 17 | + | ||
| 18 | + | Creator access is currently invite-only via the waitlist. To apply: | |
| 19 | + | ||
| 20 | + | 1. Go to the waitlist page | |
| 21 | + | 2. Write a short pitch (20-500 characters) about what you want to create | |
| 22 | + | 3. Submit your application | |
| 23 | + | ||
| 24 | + | Applications are reviewed in waves. You'll get an email when you're approved. | |
| 25 | + | ||
| 26 | + | **Requirements:** | |
| 27 | + | - Verified email address | |
| 28 | + | - You haven't already applied | |
| 29 | + | - You don't already have creator access | |
| 30 | + | ||
| 31 | + | ## Connect Stripe | |
| 32 | + | ||
| 33 | + | Once approved as a creator, connect your Stripe account to receive payments: | |
| 34 | + | ||
| 35 | + | 1. Go to your **Dashboard** | |
| 36 | + | 2. Click **Connect Stripe** | |
| 37 | + | 3. Follow the Stripe Connect onboarding flow | |
| 38 | + | 4. Complete identity verification (Stripe requirement) | |
| 39 | + | ||
| 40 | + | Payments go directly to your Stripe account. We never hold or touch your revenue. | |
| 41 | + | ||
| 42 | + | ## Create Your First Project | |
| 43 | + | ||
| 44 | + | Projects are how you organize your work. Think of them like albums, podcast feeds, or product lines. | |
| 45 | + | ||
| 46 | + | 1. From your Dashboard, click **New Project** | |
| 47 | + | 2. Enter a **slug** (URL-safe name, e.g., `my-album`) and a **title** | |
| 48 | + | 3. Choose a category (Music, Podcast, Software, Blog, Art, etc.) | |
| 49 | + | 4. Add a description | |
| 50 | + | ||
| 51 | + | Your project starts as a draft. You'll make it public after adding items. | |
| 52 | + | ||
| 53 | + | ## Create Your First Item | |
| 54 | + | ||
| 55 | + | Items are individual pieces of content inside a project. | |
| 56 | + | ||
| 57 | + | 1. Navigate to your project | |
| 58 | + | 2. Click **New Item** | |
| 59 | + | 3. Choose an item type: | |
| 60 | + | ||
| 61 | + | | Type | Best For | | |
| 62 | + | |------|----------| | |
| 63 | + | | **Audio** | Songs, podcast episodes, sound effects | | |
| 64 | + | | **Text** | Articles, stories, documentation | | |
| 65 | + | | **Digital** | Software, plugins, files, images | | |
| 66 | + | ||
| 67 | + | 4. Set a title and price (free, fixed, or pay-what-you-want) | |
| 68 | + | 5. Upload your content or write your text | |
| 69 | + | ||
| 70 | + | ## Publish | |
| 71 | + | ||
| 72 | + | 1. Make your item public: edit the item and toggle **is_public** | |
| 73 | + | 2. Make your project public: edit the project and set **visibility** to public | |
| 74 | + | 3. Share your link: `/u/yourname` | |
| 75 | + | ||
| 76 | + | ## First 15 Minutes Checklist | |
| 77 | + | ||
| 78 | + | - [ ] Account created and email verified | |
| 79 | + | - [ ] Waitlist application submitted (or creator access granted) | |
| 80 | + | - [ ] Stripe connected | |
| 81 | + | - [ ] First project created with title, slug, and category | |
| 82 | + | - [ ] First item created with content uploaded | |
| 83 | + | - [ ] Item and project published | |
| 84 | + | ||
| 85 | + | --- | |
| 86 | + | ||
| 87 | + | ## See Also | |
| 88 | + | ||
| 89 | + | - [Projects](./02-projects.md) — Organizing your work | |
| 90 | + | - [Items](./03-items.md) — Content types and settings | |
| 91 | + | - [Pricing & Monetization](./07-pricing.md) — Setting prices and getting paid |
| @@ -0,0 +1,65 @@ | |||
| 1 | + | # Projects | |
| 2 | + | ||
| 3 | + | Projects group your items under a single page with its own URL, settings, and feed. | |
| 4 | + | ||
| 5 | + | --- | |
| 6 | + | ||
| 7 | + | ## Creating a Project | |
| 8 | + | ||
| 9 | + | From your Dashboard, click **New Project**. You'll need: | |
| 10 | + | ||
| 11 | + | - **Slug**: URL-safe name (e.g., `my-album`). This becomes `/p/my-album`. Cannot be changed after creation. | |
| 12 | + | - **Title**: Display name shown on the project page. | |
| 13 | + | ||
| 14 | + | ## Project Settings | |
| 15 | + | ||
| 16 | + | Edit your project to configure: | |
| 17 | + | ||
| 18 | + | - **Description**: Text shown on the project page | |
| 19 | + | - **Category**: Helps fans discover your work. Choose from 12 built-in categories (Music, Band, Podcast, Blog, Software, Art, etc.) or create your own | |
| 20 | + | - **Visibility**: Draft (only you can see it) or Public (visible to everyone) | |
| 21 | + | ||
| 22 | + | ## Categories | |
| 23 | + | ||
| 24 | + | Categories are used for discovery and filtering. Each project can have one category. Built-in options include: | |
| 25 | + | ||
| 26 | + | | Category | Typical Use | | |
| 27 | + | |----------|-------------| | |
| 28 | + | | Music | Albums, singles, EPs | | |
| 29 | + | | Band | Band or artist pages | | |
| 30 | + | | Podcast | Podcast feeds | | |
| 31 | + | | Blog | Writing and newsletters | | |
| 32 | + | | Software | Apps, plugins, tools | | |
| 33 | + | | Art | Visual art, photography | | |
| 34 | + | | Education | Courses, tutorials | | |
| 35 | + | | Games | Game projects | | |
| 36 | + | ||
| 37 | + | ## Visibility | |
| 38 | + | ||
| 39 | + | Projects start as **drafts**. Draft projects and their items are invisible to everyone except you. | |
| 40 | + | ||
| 41 | + | To publish, set visibility to `public`. All published items within the project become discoverable. | |
| 42 | + | ||
| 43 | + | ## Organizing Items | |
| 44 | + | ||
| 45 | + | Items are ordered within a project. You can reorder them from the project dashboard. Each item belongs to exactly one project. | |
| 46 | + | ||
| 47 | + | ## Blog | |
| 48 | + | ||
| 49 | + | Every project gets a blog. Blog posts use markdown, support drafts, and are included in the project's RSS feed. See [Content Types](./04-content.md) for details. | |
| 50 | + | ||
| 51 | + | ## RSS Feeds | |
| 52 | + | ||
| 53 | + | Each project automatically generates an RSS feed containing published items and blog posts. Fans can subscribe using any feed reader. | |
| 54 | + | ||
| 55 | + | ## Deleting a Project | |
| 56 | + | ||
| 57 | + | Deleting a project removes it and all its items permanently. This cannot be undone. Active subscriptions should be canceled first. | |
| 58 | + | ||
| 59 | + | --- | |
| 60 | + | ||
| 61 | + | ## See Also | |
| 62 | + | ||
| 63 | + | - [Items](./03-items.md) — Creating content within projects | |
| 64 | + | - [Content Types](./04-content.md) — Audio, text, digital, and blog | |
| 65 | + | - [Analytics & Data](./09-analytics.md) — Project-level stats |
| @@ -0,0 +1,66 @@ | |||
| 1 | + | # Items | |
| 2 | + | ||
| 3 | + | Items are individual pieces of content — a song, an article, a software release, a file download. | |
| 4 | + | ||
| 5 | + | --- | |
| 6 | + | ||
| 7 | + | ## Item Types | |
| 8 | + | ||
| 9 | + | | Type | Content | Player/Viewer | Chapters | Versions | | |
| 10 | + | |------|---------|--------------|----------|----------| | |
| 11 | + | | **Audio** | MP3, WAV, FLAC, OGG | In-browser streaming player | Yes | Yes | | |
| 12 | + | | **Text** | Markdown | Clean reading view | No | No | | |
| 13 | + | | **Digital** | Any file (ZIP, DMG, EXE, etc.) | Download link | No | Yes | | |
| 14 | + | ||
| 15 | + | Choose the type when creating the item. It cannot be changed afterward. | |
| 16 | + | ||
| 17 | + | ## Creating an Item | |
| 18 | + | ||
| 19 | + | 1. Navigate to your project | |
| 20 | + | 2. Click **New Item** | |
| 21 | + | 3. Set a **title** and **item type** | |
| 22 | + | 4. Optionally set a price (defaults to free) | |
| 23 | + | ||
| 24 | + | ## Editing an Item | |
| 25 | + | ||
| 26 | + | From the item settings, you can update: | |
| 27 | + | ||
| 28 | + | - **Title**: Display name | |
| 29 | + | - **Description**: Shown on the item page | |
| 30 | + | - **Price**: Free, fixed amount, or pay-what-you-want (see [Pricing](./07-pricing.md)) | |
| 31 | + | - **Tags**: Hierarchical tags for discovery | |
| 32 | + | - **Cover image**: Displayed on the item card | |
| 33 | + | ||
| 34 | + | ## Publishing | |
| 35 | + | ||
| 36 | + | Items start as drafts. To publish: | |
| 37 | + | ||
| 38 | + | 1. Edit the item and set **is_public** to true | |
| 39 | + | 2. Make sure the parent project is also public | |
| 40 | + | ||
| 41 | + | Published items appear on your profile, in search results, and in RSS feeds. | |
| 42 | + | ||
| 43 | + | ## Scheduling | |
| 44 | + | ||
| 45 | + | Set a future publish date to schedule content releases. The item becomes visible automatically at the scheduled time. | |
| 46 | + | ||
| 47 | + | ## Bulk Operations | |
| 48 | + | ||
| 49 | + | From the project dashboard, you can perform bulk operations on items: publish, unpublish, or delete multiple items at once. | |
| 50 | + | ||
| 51 | + | ## Duplicating an Item | |
| 52 | + | ||
| 53 | + | Duplicate an item to create a copy with the same settings and metadata. Useful for creating similar items quickly. Content (files, text) is not duplicated — only metadata. | |
| 54 | + | ||
| 55 | + | ## Deleting an Item | |
| 56 | + | ||
| 57 | + | Deleting an item removes it permanently. Fans who purchased it will lose access. Active download codes and license keys for the item are invalidated. | |
| 58 | + | ||
| 59 | + | --- | |
| 60 | + | ||
| 61 | + | ## See Also | |
| 62 | + | ||
| 63 | + | - [Content Types](./04-content.md) — Uploading and managing content | |
| 64 | + | - [Versions](./05-versions.md) — Release versioned files | |
| 65 | + | - [Chapters](./06-chapters.md) — Audio chapter markers | |
| 66 | + | - [Pricing & Monetization](./07-pricing.md) — Free, fixed, and PWYW |
| @@ -0,0 +1,55 @@ | |||
| 1 | + | # Content Types | |
| 2 | + | ||
| 3 | + | What you can upload and how it's delivered to fans. | |
| 4 | + | ||
| 5 | + | --- | |
| 6 | + | ||
| 7 | + | ## Audio | |
| 8 | + | ||
| 9 | + | Upload audio files in MP3, WAV, FLAC, or OGG format. Each audio item gets: | |
| 10 | + | ||
| 11 | + | - **In-browser player**: Stream without downloading. Custom player with playback controls. | |
| 12 | + | - **Cover image**: Album art displayed in the player and on the item card. | |
| 13 | + | - **Chapters**: Timestamp markers for navigating within the track. See [Chapters](./06-chapters.md). | |
| 14 | + | - **Downloads**: Fans can download the original file after purchase. | |
| 15 | + | ||
| 16 | + | Upload your audio file from the item's content tab. Large files are supported — there are no per-file size limits. | |
| 17 | + | ||
| 18 | + | ## Text | |
| 19 | + | ||
| 20 | + | Write directly in the markdown editor. Features include: | |
| 21 | + | ||
| 22 | + | - **Live preview**: See rendered output as you type | |
| 23 | + | - **Word count**: Automatically calculated | |
| 24 | + | - **Reading time**: Estimated based on word count | |
| 25 | + | - **Full markdown support**: Headers, lists, code blocks, links, images, tables | |
| 26 | + | ||
| 27 | + | Text content is stored and rendered on the platform. Fans read it in a clean, distraction-free view. | |
| 28 | + | ||
| 29 | + | ## Digital Downloads | |
| 30 | + | ||
| 31 | + | Upload any file type. Digital items support: | |
| 32 | + | ||
| 33 | + | - **Any format**: ZIP, DMG, EXE, PDF, images, fonts — whatever you make | |
| 34 | + | - **Versioned releases**: Upload new versions with changelogs. See [Versions](./05-versions.md). | |
| 35 | + | - **Download tracking**: See how many times each version has been downloaded | |
| 36 | + | - **License keys**: Auto-generated keys for software products. See [Pricing](./07-pricing.md). | |
| 37 | + | ||
| 38 | + | ## Blog Posts | |
| 39 | + | ||
| 40 | + | Every project includes a blog. Blog posts use the same markdown editor as text items, plus: | |
| 41 | + | ||
| 42 | + | - **Draft/publish workflow**: Write drafts, publish when ready | |
| 43 | + | - **Included in RSS**: Published posts appear in the project's RSS feed | |
| 44 | + | - **Included in data exports**: Blog posts export alongside all other project data | |
| 45 | + | - **Separate from items**: Blog posts don't appear in your item list — they have their own tab | |
| 46 | + | ||
| 47 | + | Blog posts are always free. Use them for updates, announcements, liner notes, or changelogs. | |
| 48 | + | ||
| 49 | + | --- | |
| 50 | + | ||
| 51 | + | ## See Also | |
| 52 | + | ||
| 53 | + | - [Chapters](./06-chapters.md) — Audio timestamp markers | |
| 54 | + | - [Versions](./05-versions.md) — Versioned file releases | |
| 55 | + | - [Items](./03-items.md) — Item types and settings |
| @@ -0,0 +1,51 @@ | |||
| 1 | + | # Versions | |
| 2 | + | ||
| 3 | + | Track releases of digital and audio items with version numbers and changelogs. | |
| 4 | + | ||
| 5 | + | --- | |
| 6 | + | ||
| 7 | + | ## Creating a Version | |
| 8 | + | ||
| 9 | + | From the item dashboard, add a new version: | |
| 10 | + | ||
| 11 | + | - **Version number** (required): Any string up to 50 characters (e.g., `1.0.0`, `v2`, `2024-03-01`) | |
| 12 | + | - **Changelog** (optional): Up to 10,000 characters describing what changed | |
| 13 | + | - **File**: Optionally attach a new file to this version | |
| 14 | + | ||
| 15 | + | ## How is_current Works | |
| 16 | + | ||
| 17 | + | Only one version per item can be marked as "current." When you create a new version: | |
| 18 | + | ||
| 19 | + | 1. All existing versions are marked `is_current = false` | |
| 20 | + | 2. The new version is marked `is_current = true` | |
| 21 | + | ||
| 22 | + | This happens atomically — there's no moment where zero or multiple versions are current. | |
| 23 | + | ||
| 24 | + | Fans downloading the item get the current version by default. | |
| 25 | + | ||
| 26 | + | ## Changelogs | |
| 27 | + | ||
| 28 | + | Use changelogs to tell fans what changed. Changelogs are plain text, up to 10,000 characters. Keep them concise — fans want to know what's new, not read a novel. | |
| 29 | + | ||
| 30 | + | ## Download Tracking | |
| 31 | + | ||
| 32 | + | Each version tracks its download count independently. This helps you see which versions fans are using. | |
| 33 | + | ||
| 34 | + | ## Listing Versions | |
| 35 | + | ||
| 36 | + | The public version list is ordered newest-first (by creation date). Only published items expose their version list — draft items return a 404. | |
| 37 | + | ||
| 38 | + | ## Validation | |
| 39 | + | ||
| 40 | + | | Field | Rule | | |
| 41 | + | |-------|------| | |
| 42 | + | | Version number | 1-50 characters, required | | |
| 43 | + | | Changelog | 0-10,000 characters, optional | | |
| 44 | + | ||
| 45 | + | --- | |
| 46 | + | ||
| 47 | + | ## See Also | |
| 48 | + | ||
| 49 | + | - [Items](./03-items.md) — Item types and settings | |
| 50 | + | - [Content Types](./04-content.md) — Upload formats | |
| 51 | + | - [Analytics & Data](./09-analytics.md) — Download tracking and exports |
| @@ -0,0 +1,46 @@ | |||
| 1 | + | # Chapters | |
| 2 | + | ||
| 3 | + | Chapters are timestamp markers for audio items. They let fans jump to specific sections within a track. | |
| 4 | + | ||
| 5 | + | --- | |
| 6 | + | ||
| 7 | + | ## Creating Chapters | |
| 8 | + | ||
| 9 | + | From the item dashboard, add chapters to any audio item: | |
| 10 | + | ||
| 11 | + | - **Title** (required): 1-200 characters | |
| 12 | + | - **Start seconds** (required): Timestamp where the chapter begins (e.g., `0`, `30.5`, `125`) | |
| 13 | + | - **Sort order**: Integer controlling display order (default: 0) | |
| 14 | + | ||
| 15 | + | ## Editing and Deleting | |
| 16 | + | ||
| 17 | + | Update any chapter's title, timestamp, or sort order. Delete chapters you no longer need. Changes take effect immediately. | |
| 18 | + | ||
| 19 | + | ## Ordering | |
| 20 | + | ||
| 21 | + | Chapters are displayed sorted by `sort_order` first, then by `start_seconds`. If all chapters have the same sort order, they appear in timestamp order. | |
| 22 | + | ||
| 23 | + | Create chapters in any order — the system sorts them for display. | |
| 24 | + | ||
| 25 | + | ## Ownership | |
| 26 | + | ||
| 27 | + | Only the item's creator can add, edit, or delete chapters. Other creators receive a 403 if they try. | |
| 28 | + | ||
| 29 | + | ## Validation | |
| 30 | + | ||
| 31 | + | | Field | Rule | | |
| 32 | + | |-------|------| | |
| 33 | + | | Title | 1-200 characters, required | | |
| 34 | + | | Start seconds | Non-negative number, required | | |
| 35 | + | | Sort order | Integer, default 0 | | |
| 36 | + | ||
| 37 | + | ## Draft Items | |
| 38 | + | ||
| 39 | + | Chapter lists are only visible for published items. If the item is still a draft, the chapters endpoint returns 404 for unauthenticated users. The creator can still manage chapters while the item is in draft. | |
| 40 | + | ||
| 41 | + | --- | |
| 42 | + | ||
| 43 | + | ## See Also | |
| 44 | + | ||
| 45 | + | - [Content Types](./04-content.md) — Audio uploads and playback | |
| 46 | + | - [Items](./03-items.md) — Item types and settings |
| @@ -0,0 +1,78 @@ | |||
| 1 | + | # Pricing & Monetization | |
| 2 | + | ||
| 3 | + | Set prices, accept payments, and manage monetization tools. | |
| 4 | + | ||
| 5 | + | --- | |
| 6 | + | ||
| 7 | + | ## Pricing Models | |
| 8 | + | ||
| 9 | + | | Model | How It Works | | |
| 10 | + | |-------|-------------| | |
| 11 | + | | **Free** | Price set to $0. Fans add to library without payment. | | |
| 12 | + | | **Fixed price** | You set the price. Fan pays exactly that amount. | | |
| 13 | + | | **Pay-what-you-want** | Fan chooses the amount. You can set a minimum. | | |
| 14 | + | ||
| 15 | + | Set prices per item when creating or editing. | |
| 16 | + | ||
| 17 | + | ## Subscriptions | |
| 18 | + | ||
| 19 | + | Offer recurring monthly subscriptions per project: | |
| 20 | + | ||
| 21 | + | - Create multiple tiers with different prices | |
| 22 | + | - Toggle tiers active/inactive without deleting them | |
| 23 | + | - Fans subscribe through Stripe Checkout | |
| 24 | + | - Stripe handles billing, renewals, and cancellations | |
| 25 | + | ||
| 26 | + | Subscription lifecycle events (active, past due, canceled, renewed) are processed automatically via Stripe webhooks. | |
| 27 | + | ||
| 28 | + | ## License Keys | |
| 29 | + | ||
| 30 | + | For software products, license keys are generated automatically on purchase: | |
| 31 | + | ||
| 32 | + | - **Configurable activation limits**: Set how many machines can activate a key | |
| 33 | + | - **Machine tracking**: See which machines have activated | |
| 34 | + | - **Public validation endpoint**: Your software can phone home to verify keys | |
| 35 | + | - **Revocable**: Deactivate keys if needed | |
| 36 | + | ||
| 37 | + | License keys appear in the fan's library after purchase. | |
| 38 | + | ||
| 39 | + | ## Discount Codes | |
| 40 | + | ||
| 41 | + | Create promotional codes with: | |
| 42 | + | ||
| 43 | + | - **Percentage or fixed amount** off the price | |
| 44 | + | - **Scope**: Apply to a specific item or all your items | |
| 45 | + | - **Usage limits**: Cap how many times a code can be used | |
| 46 | + | - **Expiration dates**: Codes stop working after a date you set | |
| 47 | + | - **Auto-apply via URL**: Share a link with `?discount=CODE` to pre-fill | |
| 48 | + | ||
| 49 | + | ## Download Codes | |
| 50 | + | ||
| 51 | + | Generate single-use codes for free access: | |
| 52 | + | ||
| 53 | + | - **Batch generation**: Create many codes at once | |
| 54 | + | - **Optional max uses**: Limit how many times each code works | |
| 55 | + | - **Optional expiration**: Codes expire after a date | |
| 56 | + | - **Useful for**: Press copies, review access, promotional giveaways | |
| 57 | + | ||
| 58 | + | ## Payment Flow | |
| 59 | + | ||
| 60 | + | All payments go through Stripe Connect: | |
| 61 | + | ||
| 62 | + | ``` | |
| 63 | + | Fan pays → Stripe → Creator's connected account | |
| 64 | + | ``` | |
| 65 | + | ||
| 66 | + | We take 0% platform fee. Only Stripe's processing fee (~3%) applies. | |
| 67 | + | ||
| 68 | + | ## Refunds | |
| 69 | + | ||
| 70 | + | Refund policies are set by individual creators. Refunds are processed through Stripe. | |
| 71 | + | ||
| 72 | + | --- | |
| 73 | + | ||
| 74 | + | ## See Also | |
| 75 | + | ||
| 76 | + | - [How We Work](../about/how-we-work.md) — Business model and platform pricing | |
| 77 | + | - [Items](./03-items.md) — Setting item prices | |
| 78 | + | - [Audience & Communication](./08-audience.md) — Building your fan base |
| @@ -0,0 +1,55 @@ | |||
| 1 | + | # Audience & Communication | |
| 2 | + | ||
| 3 | + | Build and maintain your fan base. Communicate directly. | |
| 4 | + | ||
| 5 | + | --- | |
| 6 | + | ||
| 7 | + | ## Follows | |
| 8 | + | ||
| 9 | + | Fans can follow your user account, individual projects, or tags. Follows power: | |
| 10 | + | ||
| 11 | + | - **Personal feed**: Fans see new items and blog posts from followed creators | |
| 12 | + | - **Follower counts**: Visible on your profile and project pages | |
| 13 | + | ||
| 14 | + | Following is free and doesn't require a purchase. | |
| 15 | + | ||
| 16 | + | ## Contact Sharing | |
| 17 | + | ||
| 18 | + | When a fan purchases from you, they can opt in to share their email address. This creates a direct connection you can use outside the platform. | |
| 19 | + | ||
| 20 | + | ### How It Works | |
| 21 | + | ||
| 22 | + | 1. At checkout, the fan sees a "Share your email with this creator" option | |
| 23 | + | 2. If they opt in, their email appears in your contacts | |
| 24 | + | 3. Fans can revoke sharing at any time via `DELETE /api/contacts/{seller_id}` | |
| 25 | + | 4. Revocation is idempotent — revoking twice is fine | |
| 26 | + | ||
| 27 | + | ### For Fans | |
| 28 | + | ||
| 29 | + | You control your contact info. Shared your email and changed your mind? Revoke it. The creator loses access immediately. | |
| 30 | + | ||
| 31 | + | ### For Creators | |
| 32 | + | ||
| 33 | + | Respect revocations. Don't export contact lists and continue emailing fans who revoked. The platform enforces revocation — build trust by honoring it. | |
| 34 | + | ||
| 35 | + | ## Broadcast Email | |
| 36 | + | ||
| 37 | + | Send updates to fans who opted in to contact sharing. Planned but not yet live — see the [Roadmap](../about/roadmap.md). | |
| 38 | + | ||
| 39 | + | ## RSS | |
| 40 | + | ||
| 41 | + | Every project generates an RSS feed automatically. Fans subscribe via any feed reader. Feeds include both published items and blog posts. | |
| 42 | + | ||
| 43 | + | Fans also get a personal feed across all creators and projects they follow. | |
| 44 | + | ||
| 45 | + | ## Notifications | |
| 46 | + | ||
| 47 | + | Sale and follower notifications via email are planned. Currently, check your Dashboard for transaction history. | |
| 48 | + | ||
| 49 | + | --- | |
| 50 | + | ||
| 51 | + | ## See Also | |
| 52 | + | ||
| 53 | + | - [For Fans](./12-for-fans.md) — The fan experience | |
| 54 | + | - [Analytics & Data](./09-analytics.md) — Tracking your audience | |
| 55 | + | - [Profile & Customization](./11-profile.md) — Your public presence |
| @@ -0,0 +1,53 @@ | |||
| 1 | + | # Analytics & Data | |
| 2 | + | ||
| 3 | + | Track performance, review transactions, and export everything. | |
| 4 | + | ||
| 5 | + | --- | |
| 6 | + | ||
| 7 | + | ## Dashboard Analytics | |
| 8 | + | ||
| 9 | + | Your creator dashboard shows: | |
| 10 | + | ||
| 11 | + | - Revenue and sales counts per project | |
| 12 | + | - Recent transactions | |
| 13 | + | - Follower counts | |
| 14 | + | ||
| 15 | + | Full analytics with charts and trends are planned. The current dashboard gives you the numbers that matter. | |
| 16 | + | ||
| 17 | + | ## Transaction History | |
| 18 | + | ||
| 19 | + | View complete purchase and sales history from your Dashboard. Transactions include: | |
| 20 | + | ||
| 21 | + | - Buyer info (username, email if shared) | |
| 22 | + | - Item and project | |
| 23 | + | - Amount paid | |
| 24 | + | - Payment status | |
| 25 | + | - Date | |
| 26 | + | ||
| 27 | + | Filter by project, date range, or status. | |
| 28 | + | ||
| 29 | + | ## Data Exports | |
| 30 | + | ||
| 31 | + | Export all your data at any time. Five export types are available: | |
| 32 | + | ||
| 33 | + | | Export | Format | Contents | | |
| 34 | + | |--------|--------|----------| | |
| 35 | + | | **Projects** | JSON | All project metadata, settings, categories | | |
| 36 | + | | **Items** | JSON | All items with metadata, pricing, tags | | |
| 37 | + | | **Blog posts** | JSON | All blog posts with content and publish status | | |
| 38 | + | | **Sales** | CSV | Complete sales transaction history | | |
| 39 | + | | **Purchases** | CSV | Your purchase history as a fan | | |
| 40 | + | ||
| 41 | + | Exports are generated on demand and download as files. No limits on how often you export. | |
| 42 | + | ||
| 43 | + | ## Your Data Rights | |
| 44 | + | ||
| 45 | + | You own your data. We facilitate hosting and delivery, but everything is exportable. If you leave, you take it all with you. See [How We Work](../about/how-we-work.md) for the full data ownership commitment. | |
| 46 | + | ||
| 47 | + | --- | |
| 48 | + | ||
| 49 | + | ## See Also | |
| 50 | + | ||
| 51 | + | - [Versions](./05-versions.md) — Download tracking per version | |
| 52 | + | - [Pricing & Monetization](./07-pricing.md) — Revenue and payments | |
| 53 | + | - [Audience & Communication](./08-audience.md) — Fan relationships |
| @@ -0,0 +1,61 @@ | |||
| 1 | + | # Security | |
| 2 | + | ||
| 3 | + | Protect your account with multiple layers of authentication. | |
| 4 | + | ||
| 5 | + | --- | |
| 6 | + | ||
| 7 | + | ## Two-Factor Authentication (2FA/TOTP) | |
| 8 | + | ||
| 9 | + | Add time-based one-time passwords as a second factor: | |
| 10 | + | ||
| 11 | + | 1. Go to your account security settings | |
| 12 | + | 2. Scan the QR code with an authenticator app (Google Authenticator, Authy, 1Password, etc.) | |
| 13 | + | 3. Enter the 6-digit code to confirm setup | |
| 14 | + | 4. Save your 10 backup codes somewhere safe | |
| 15 | + | ||
| 16 | + | When 2FA is enabled, you'll enter a code from your authenticator app after your password on each login. | |
| 17 | + | ||
| 18 | + | ### Backup Codes | |
| 19 | + | ||
| 20 | + | You get 10 single-use backup codes at setup. Each code works once. If you lose access to your authenticator app, use a backup code to log in, then reconfigure 2FA. | |
| 21 | + | ||
| 22 | + | ## Passkeys (WebAuthn) | |
| 23 | + | ||
| 24 | + | Passkeys offer passwordless, phishing-resistant login: | |
| 25 | + | ||
| 26 | + | - Register a passkey from your security settings (fingerprint, Face ID, hardware key) | |
| 27 | + | - Log in by touching your device — no password needed | |
| 28 | + | - Multiple passkeys supported (register your phone, laptop, and a hardware key) | |
| 29 | + | - Phishing-resistant by design — passkeys are bound to the domain | |
| 30 | + | ||
| 31 | + | ## Session Management | |
| 32 | + | ||
| 33 | + | View and control active sessions: | |
| 34 | + | ||
| 35 | + | - See all active sessions with device type, IP address, and last activity | |
| 36 | + | - Revoke individual sessions (log out a specific device) | |
| 37 | + | - Revoke all other sessions at once (nuclear option) | |
| 38 | + | ||
| 39 | + | ## Account Lockout | |
| 40 | + | ||
| 41 | + | After 5 failed login attempts, your account locks for 15 minutes. During lockout: | |
| 42 | + | ||
| 43 | + | - Login attempts are rejected regardless of password | |
| 44 | + | - An email is sent with a bypass link (proves you control the email) | |
| 45 | + | - Lockout clears automatically after 15 minutes | |
| 46 | + | ||
| 47 | + | ## New Device Notifications | |
| 48 | + | ||
| 49 | + | Opt in to receive an email whenever a new device logs into your account. Useful for detecting unauthorized access early. | |
| 50 | + | ||
| 51 | + | ## Password Breach Checking | |
| 52 | + | ||
| 53 | + | On signup and password change, your password is checked against the Have I Been Pwned database using k-anonymity (your password is never sent to HIBP). If your password appears in known breaches, you'll get a warning. It's advisory — you can proceed, but you should pick a stronger password. | |
| 54 | + | ||
| 55 | + | --- | |
| 56 | + | ||
| 57 | + | ## See Also | |
| 58 | + | ||
| 59 | + | - [Getting Started](./01-getting-started.md) — Account setup | |
| 60 | + | - [Profile & Customization](./11-profile.md) — Account settings | |
| 61 | + | - [FAQ](../support/faq.md) — Security questions |
| @@ -0,0 +1,46 @@ | |||
| 1 | + | # Profile & Customization | |
| 2 | + | ||
| 3 | + | Your public presence on the platform. | |
| 4 | + | ||
| 5 | + | --- | |
| 6 | + | ||
| 7 | + | ## Your Profile Page | |
| 8 | + | ||
| 9 | + | Every account gets a profile at `/u/yourname`. It shows: | |
| 10 | + | ||
| 11 | + | - Display name and username | |
| 12 | + | - Bio | |
| 13 | + | - Avatar and cover image | |
| 14 | + | - Published projects and items | |
| 15 | + | - Custom links | |
| 16 | + | - Follower count | |
| 17 | + | ||
| 18 | + | ## Display Name | |
| 19 | + | ||
| 20 | + | Your display name appears on your profile, in search results, and on item pages. It can be different from your username. Update it from account settings. | |
| 21 | + | ||
| 22 | + | ## Bio | |
| 23 | + | ||
| 24 | + | A short description shown on your profile page. Keep it concise — this is the first thing fans see. | |
| 25 | + | ||
| 26 | + | ## Avatar & Cover Image | |
| 27 | + | ||
| 28 | + | Upload a profile avatar and a cover image for your profile page. Images are resized automatically. | |
| 29 | + | ||
| 30 | + | ## Custom Links | |
| 31 | + | ||
| 32 | + | Add external links to your profile — your website, social media, other platforms. Each link has a title and URL. Links appear on your profile page. | |
| 33 | + | ||
| 34 | + | Manage links from your Dashboard. Add, edit, reorder, or remove links at any time. | |
| 35 | + | ||
| 36 | + | ## Username | |
| 37 | + | ||
| 38 | + | Your username is set at signup and determines your profile URL (`/u/yourname`). Choose carefully — it's part of your public identity on the platform. | |
| 39 | + | ||
| 40 | + | --- | |
| 41 | + | ||
| 42 | + | ## See Also | |
| 43 | + | ||
| 44 | + | - [Getting Started](./01-getting-started.md) — Account creation | |
| 45 | + | - [Security](./10-security.md) — Account protection | |
| 46 | + | - [Audience & Communication](./08-audience.md) — Building your audience |
| @@ -0,0 +1,71 @@ | |||
| 1 | + | # For Fans | |
| 2 | + | ||
| 3 | + | Everything you can do as a fan on Makenot.work — no creator subscription needed. | |
| 4 | + | ||
| 5 | + | --- | |
| 6 | + | ||
| 7 | + | ## Discover | |
| 8 | + | ||
| 9 | + | Browse content on the Discover page: | |
| 10 | + | ||
| 11 | + | - **Search**: Full-text fuzzy search across titles, descriptions, and usernames | |
| 12 | + | - **Filters**: Filter by item type (Audio, Text, Digital), price range, tags, and project category | |
| 13 | + | - **Sort**: Newest, price (low/high), or popularity | |
| 14 | + | ||
| 15 | + | ## Purchasing | |
| 16 | + | ||
| 17 | + | When you find something you want: | |
| 18 | + | ||
| 19 | + | 1. Click to view the item | |
| 20 | + | 2. Click **Buy** (or **Add to Library** for free items) | |
| 21 | + | 3. For paid items, complete checkout through Stripe | |
| 22 | + | 4. The item appears in your Library immediately | |
| 23 | + | ||
| 24 | + | ### Pay-What-You-Want | |
| 25 | + | ||
| 26 | + | Some items let you choose your price. You'll see a minimum (which can be $0) and can pay more if you want to support the creator. | |
| 27 | + | ||
| 28 | + | ### Contact Sharing | |
| 29 | + | ||
| 30 | + | At checkout, you can opt in to share your email with the creator. This is completely optional. If you share and later change your mind, you can revoke access anytime. | |
| 31 | + | ||
| 32 | + | ## Library | |
| 33 | + | ||
| 34 | + | Your Library holds everything you've purchased or claimed: | |
| 35 | + | ||
| 36 | + | - **Download files**: Original quality, no DRM | |
| 37 | + | - **View license keys**: For software purchases | |
| 38 | + | - **Stream audio**: Play in the browser | |
| 39 | + | - **Read text**: Clean reading view | |
| 40 | + | ||
| 41 | + | Access your Library from the navigation bar when logged in. | |
| 42 | + | ||
| 43 | + | ## Following | |
| 44 | + | ||
| 45 | + | Follow creators, projects, or tags to build a personalized feed: | |
| 46 | + | ||
| 47 | + | - **Follow a creator**: See all their new content | |
| 48 | + | - **Follow a project**: See new items and blog posts from that project | |
| 49 | + | - **Follow a tag**: See new items tagged with topics you care about | |
| 50 | + | ||
| 51 | + | Your feed shows new content from everything you follow. | |
| 52 | + | ||
| 53 | + | ## Contact Sharing & Revocation | |
| 54 | + | ||
| 55 | + | If you shared your email with a creator during a purchase and want to revoke it: | |
| 56 | + | ||
| 57 | + | - The creator immediately loses access to your email | |
| 58 | + | - Revocation is permanent until you make a new purchase and opt in again | |
| 59 | + | - You can revoke even if you made multiple purchases from the same creator | |
| 60 | + | ||
| 61 | + | ## Free Accounts | |
| 62 | + | ||
| 63 | + | Fan accounts are free. You never pay the platform — only for content you choose to buy. | |
| 64 | + | ||
| 65 | + | --- | |
| 66 | + | ||
| 67 | + | ## See Also | |
| 68 | + | ||
| 69 | + | - [Getting Started](./01-getting-started.md) — Creating your account | |
| 70 | + | - [Audience & Communication](./08-audience.md) — How contact sharing works | |
| 71 | + | - [FAQ](../support/faq.md) — Common questions |
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | CREATE TABLE contact_revocations ( | |
| 2 | - | buyer_id UUID NOT NULL REFERENCES users(id), | |
| 3 | - | seller_id UUID NOT NULL REFERENCES users(id), | |
| 2 | + | buyer_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, | |
| 3 | + | seller_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, | |
| 4 | 4 | revoked_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), | |
| 5 | 5 | PRIMARY KEY (buyer_id, seller_id) | |
| 6 | 6 | ); |
| @@ -0,0 +1 @@ | |||
| 1 | + | CREATE INDEX idx_transactions_stripe_payment_intent_id ON transactions(stripe_payment_intent_id); |
| @@ -8,11 +8,19 @@ | |||
| 8 | 8 | //! mnw-admin spam <username> Mark application as spam | |
| 9 | 9 | //! mnw-admin wave <lottery_count> Run a wave (hand-picks + lottery) | |
| 10 | 10 | //! mnw-admin stats Show waitlist/creator counts | |
| 11 | + | //! mnw-admin suspend <user> <why> Suspend a user account | |
| 12 | + | //! mnw-admin unsuspend <user> Lift a suspension | |
| 13 | + | //! mnw-admin appeals List pending appeals | |
| 14 | + | //! mnw-admin decide <user> <d> <r> Approve or deny an appeal | |
| 15 | + | //! mnw-admin revenue Platform-wide revenue report | |
| 16 | + | //! mnw-admin transactions <user> Recent sales for a user | |
| 17 | + | //! mnw-admin export <user> CSV export of a user's sales | |
| 18 | + | //! mnw-admin storage <user> S3 storage audit for a user | |
| 11 | 19 | ||
| 12 | 20 | use clap::{Parser, Subcommand}; | |
| 13 | 21 | use sqlx::PgPool; | |
| 14 | 22 | ||
| 15 | - | use makenotwork::db::{self, SelectionMethod, Username, WaitlistStatus}; | |
| 23 | + | use makenotwork::db::{self, AppealDecision, SelectionMethod, Username, WaitlistStatus}; | |
| 16 | 24 | ||
| 17 | 25 | #[derive(Parser)] | |
| 18 | 26 | #[command(name = "mnw-admin", about = "MNW admin CLI")] | |
| @@ -42,6 +50,46 @@ enum Command { | |||
| 42 | 50 | }, | |
| 43 | 51 | /// Show waitlist and creator statistics | |
| 44 | 52 | Stats, | |
| 53 | + | /// Suspend a user account | |
| 54 | + | Suspend { | |
| 55 | + | /// Username to suspend | |
| 56 | + | username: String, | |
| 57 | + | /// Reason for suspension | |
| 58 | + | reason: String, | |
| 59 | + | }, | |
| 60 | + | /// Lift a user's suspension | |
| 61 | + | Unsuspend { | |
| 62 | + | /// Username to unsuspend | |
| 63 | + | username: String, | |
| 64 | + | }, | |
| 65 | + | /// List pending suspension appeals | |
| 66 | + | Appeals, | |
| 67 | + | /// Decide a suspension appeal (approve or deny) | |
| 68 | + | Decide { | |
| 69 | + | /// Username whose appeal to decide | |
| 70 | + | username: String, | |
| 71 | + | /// Decision: "approved" or "denied" | |
| 72 | + | decision: String, | |
| 73 | + | /// Response message to the user | |
| 74 | + | response: String, | |
| 75 | + | }, | |
| 76 | + | /// Show platform-wide revenue report | |
| 77 | + | Revenue, | |
| 78 | + | /// Show recent transactions for a seller | |
| 79 | + | Transactions { | |
| 80 | + | /// Username to look up | |
| 81 | + | username: String, | |
| 82 | + | }, | |
| 83 | + | /// Export a seller's transactions as CSV to stdout | |
| 84 | + | Export { | |
| 85 | + | /// Username to export | |
| 86 | + | username: String, | |
| 87 | + | }, | |
| 88 | + | /// Audit S3 storage usage for a user | |
| 89 | + | Storage { | |
| 90 | + | /// Username to audit | |
| 91 | + | username: String, | |
| 92 | + | }, | |
| 45 | 93 | } | |
| 46 | 94 | ||
| 47 | 95 | #[tokio::main] | |
| @@ -60,11 +108,23 @@ async fn main() -> anyhow::Result<()> { | |||
| 60 | 108 | Command::Spam { username } => cmd_spam(&pool, &username).await?, | |
| 61 | 109 | Command::Wave { lottery_count } => cmd_wave(&pool, lottery_count).await?, | |
| 62 | 110 | Command::Stats => cmd_stats(&pool).await?, | |
| 111 | + | Command::Suspend { username, reason } => cmd_suspend(&pool, &username, &reason).await?, | |
| 112 | + | Command::Unsuspend { username } => cmd_unsuspend(&pool, &username).await?, | |
| 113 | + | Command::Appeals => cmd_appeals(&pool).await?, | |
| 114 | + | Command::Decide { username, decision, response } => { | |
| 115 | + | cmd_decide(&pool, &username, &decision, &response).await? | |
| 116 | + | } | |
| 117 | + | Command::Revenue => cmd_revenue(&pool).await?, | |
| 118 | + | Command::Transactions { username } => cmd_transactions(&pool, &username).await?, | |
| 119 | + | Command::Export { username } => cmd_export(&pool, &username).await?, | |
| 120 | + | Command::Storage { username } => cmd_storage(&pool, &username).await?, | |
| 63 | 121 | } | |
| 64 | 122 | ||
| 65 | 123 | Ok(()) | |
| 66 | 124 | } | |
| 67 | 125 | ||
| 126 | + | // ── Waitlist commands (existing) ── | |
| 127 | + | ||
| 68 | 128 | async fn cmd_waitlist(pool: &PgPool) -> anyhow::Result<()> { | |
| 69 | 129 | let entries = db::waitlist::get_admin_waitlist(pool, Some("pending")).await?; | |
| 70 | 130 | ||
| @@ -240,3 +300,284 @@ async fn cmd_stats(pool: &PgPool) -> anyhow::Result<()> { | |||
| 240 | 300 | ||
| 241 | 301 | Ok(()) | |
| 242 | 302 | } | |
| 303 | + | ||
| 304 | + | // ── Suspension commands ── | |
| 305 | + | ||
| 306 | + | async fn cmd_suspend(pool: &PgPool, username_str: &str, reason: &str) -> anyhow::Result<()> { | |
| 307 | + | let username = Username::new(username_str) | |
| 308 | + | .map_err(|e| anyhow::anyhow!("invalid username: {e}"))?; | |
| 309 | + | ||
| 310 | + | let user = db::users::get_user_by_username(pool, &username) | |
| 311 | + | .await? | |
| 312 | + | .ok_or_else(|| anyhow::anyhow!("user '{}' not found", username_str))?; | |
| 313 | + | ||
| 314 | + | if user.is_suspended() { | |
| 315 | + | println!("'{}' is already suspended.", username_str); | |
| 316 | + | return Ok(()); | |
| 317 | + | } | |
| 318 | + | ||
| 319 | + | db::users::suspend_user(pool, user.id, reason).await?; | |
| 320 | + | ||
| 321 | + | println!("Suspended '{}'. Reason: {}", username_str, reason); | |
| 322 | + | Ok(()) | |
| 323 | + | } | |
| 324 | + | ||
| 325 | + | async fn cmd_unsuspend(pool: &PgPool, username_str: &str) -> anyhow::Result<()> { | |
| 326 | + | let username = Username::new(username_str) | |
| 327 | + | .map_err(|e| anyhow::anyhow!("invalid username: {e}"))?; | |
| 328 | + | ||
| 329 | + | let user = db::users::get_user_by_username(pool, &username) | |
| 330 | + | .await? | |
| 331 | + | .ok_or_else(|| anyhow::anyhow!("user '{}' not found", username_str))?; | |
| 332 | + | ||
| 333 | + | if !user.is_suspended() { | |
| 334 | + | println!("'{}' is not suspended.", username_str); | |
| 335 | + | return Ok(()); | |
| 336 | + | } | |
| 337 | + | ||
| 338 | + | db::users::unsuspend_user(pool, user.id).await?; | |
| 339 | + | ||
| 340 | + | println!("Unsuspended '{}'.", username_str); | |
| 341 | + | Ok(()) | |
| 342 | + | } | |
| 343 | + | ||
| 344 | + | // ── Appeal commands ── | |
| 345 | + | ||
| 346 | + | async fn cmd_appeals(pool: &PgPool) -> anyhow::Result<()> { | |
| 347 | + | let users = db::users::get_pending_appeals(pool).await?; | |
| 348 | + | ||
| 349 | + | if users.is_empty() { | |
| 350 | + | println!("No pending appeals."); | |
| 351 | + | return Ok(()); | |
| 352 | + | } | |
| 353 | + | ||
| 354 | + | println!( | |
| 355 | + | "{:<20} {:<30} {:<12} {:<12} {}", | |
| 356 | + | "Username", "Email", "Suspended", "Appeal Date", "Appeal Text" | |
| 357 | + | ); | |
| 358 | + | println!("{}", "-".repeat(110)); | |
| 359 | + | ||
| 360 | + | for user in &users { | |
| 361 | + | let suspended = user | |
| 362 | + | .suspended_at | |
| 363 | + | .map(|t| t.format("%Y-%m-%d").to_string()) | |
| 364 | + | .unwrap_or_else(|| "-".to_string()); | |
| 365 | + | let appeal_date = user | |
| 366 | + | .appeal_submitted_at | |
| 367 | + | .map(|t| t.format("%Y-%m-%d").to_string()) | |
| 368 | + | .unwrap_or_else(|| "-".to_string()); | |
| 369 | + | let appeal = user.appeal_text.as_deref().unwrap_or(""); | |
| 370 | + | let appeal_short = if appeal.len() > 50 { | |
| 371 | + | format!("{}...", &appeal[..50]) | |
| 372 | + | } else { | |
| 373 | + | appeal.to_string() | |
| 374 | + | }; | |
| 375 | + | println!( | |
| 376 | + | "{:<20} {:<30} {:<12} {:<12} {}", | |
| 377 | + | user.username, user.email, suspended, appeal_date, appeal_short | |
| 378 | + | ); | |
| 379 | + | } | |
| 380 | + | ||
| 381 | + | println!("\n{} pending appeal(s).", users.len()); | |
| 382 | + | Ok(()) | |
| 383 | + | } | |
| 384 | + | ||
| 385 | + | async fn cmd_decide( | |
| 386 | + | pool: &PgPool, | |
| 387 | + | username_str: &str, | |
| 388 | + | decision_str: &str, | |
| 389 | + | response: &str, | |
| 390 | + | ) -> anyhow::Result<()> { | |
| 391 | + | let username = Username::new(username_str) | |
| 392 | + | .map_err(|e| anyhow::anyhow!("invalid username: {e}"))?; | |
| 393 | + | ||
| 394 | + | let user = db::users::get_user_by_username(pool, &username) | |
| 395 | + | .await? | |
| 396 | + | .ok_or_else(|| anyhow::anyhow!("user '{}' not found", username_str))?; | |
| 397 | + | ||
| 398 | + | let decision: AppealDecision = decision_str | |
| 399 | + | .parse() | |
| 400 | + | .map_err(|_| anyhow::anyhow!("invalid decision '{}': use 'approved' or 'denied'", decision_str))?; | |
| 401 | + | ||
| 402 | + | db::users::resolve_appeal(pool, user.id, decision, response).await?; | |
| 403 | + | ||
| 404 | + | match decision { | |
| 405 | + | AppealDecision::Approved => { | |
| 406 | + | println!("Appeal approved for '{}'. Suspension lifted.", username_str); | |
| 407 | + | } | |
| 408 | + | AppealDecision::Denied => { | |
| 409 | + | println!("Appeal denied for '{}'. Suspension remains.", username_str); | |
| 410 | + | } | |
| 411 | + | } | |
| 412 | + | Ok(()) | |
| 413 | + | } | |
| 414 | + | ||
| 415 | + | // ── Revenue & transaction commands ── | |
| 416 | + | ||
| 417 | + | async fn cmd_revenue(pool: &PgPool) -> anyhow::Result<()> { | |
| 418 | + | let (revenue_cents, completed, refunded) = | |
| 419 | + | db::transactions::get_platform_revenue_stats(pool).await?; | |
| 420 | + | ||
| 421 | + | let dollars = revenue_cents as f64 / 100.0; | |
| 422 | + | ||
| 423 | + | println!("Platform Revenue"); | |
| 424 | + | println!(" Total revenue: ${:.2}", dollars); | |
| 425 | + | println!(" Total sales: {}", completed); | |
| 426 | + | println!(" Total refunds: {}", refunded); | |
| 427 | + | ||
| 428 | + | Ok(()) | |
| 429 | + | } | |
| 430 | + | ||
| 431 | + | async fn cmd_transactions(pool: &PgPool, username_str: &str) -> anyhow::Result<()> { | |
| 432 | + | let username = Username::new(username_str) | |
| 433 | + | .map_err(|e| anyhow::anyhow!("invalid username: {e}"))?; | |
| 434 | + | ||
| 435 | + | let user = db::users::get_user_by_username(pool, &username) | |
| 436 | + | .await? | |
| 437 | + | .ok_or_else(|| anyhow::anyhow!("user '{}' not found", username_str))?; | |
| 438 | + | ||
| 439 | + | let txs = db::transactions::get_transactions_by_seller(pool, user.id, Some(50)).await?; | |
| 440 | + | ||
| 441 | + | if txs.is_empty() { | |
| 442 | + | println!("No transactions for '{}'.", username_str); | |
| 443 | + | return Ok(()); | |
| 444 | + | } | |
| 445 | + | ||
| 446 | + | println!( | |
| 447 | + | "{:<12} {:<30} {:>10} {:<10}", | |
| 448 | + | "Date", "Item", "Amount", "Status" | |
| 449 | + | ); | |
| 450 | + | println!("{}", "-".repeat(65)); | |
| 451 | + | ||
| 452 | + | let mut total_cents: i64 = 0; | |
| 453 | + | for tx in &txs { | |
| 454 | + | let date = tx.created_at.format("%Y-%m-%d"); | |
| 455 | + | let title = tx.item_title.as_deref().unwrap_or("(deleted)"); | |
| 456 | + | let title_short = if title.len() > 28 { | |
| 457 | + | format!("{}...", &title[..25]) | |
| 458 | + | } else { | |
| 459 | + | title.to_string() | |
| 460 | + | }; | |
| 461 | + | let amount = format!("${:.2}", tx.amount_cents as f64 / 100.0); | |
| 462 | + | println!( | |
| 463 | + | "{:<12} {:<30} {:>10} {:<10}", | |
| 464 | + | date, title_short, amount, tx.status | |
| 465 | + | ); | |
| 466 | + | if tx.status.to_string() == "completed" { | |
| 467 | + | total_cents += tx.amount_cents as i64; | |
| 468 | + | } | |
| 469 | + | } | |
| 470 | + | ||
| 471 | + | println!( | |
| 472 | + | "\n{} transaction(s), ${:.2} total revenue.", | |
| 473 | + | txs.len(), | |
| 474 | + | total_cents as f64 / 100.0 | |
| 475 | + | ); | |
| 476 | + | Ok(()) | |
| 477 | + | } | |
| 478 | + | ||
| 479 | + | // ── Export command ── | |
| 480 | + | ||
| 481 | + | async fn cmd_export(pool: &PgPool, username_str: &str) -> anyhow::Result<()> { | |
| 482 | + | let username = Username::new(username_str) | |
| 483 | + | .map_err(|e| anyhow::anyhow!("invalid username: {e}"))?; | |
| 484 | + | ||
| 485 | + | let user = db::users::get_user_by_username(pool, &username) | |
| 486 | + | .await? | |
| 487 | + | .ok_or_else(|| anyhow::anyhow!("user '{}' not found", username_str))?; | |
| 488 | + | ||
| 489 | + | let rows = db::transactions::get_seller_transactions_for_export(pool, user.id).await?; | |
| 490 | + | ||
| 491 | + | // CSV header | |
| 492 | + | println!("date,item_id,item_title,amount_cents,status,buyer_email"); | |
| 493 | + | ||
| 494 | + | for row in &rows { | |
| 495 | + | let date = row.created_at.format("%Y-%m-%dT%H:%M:%SZ"); | |
| 496 | + | let item_id = row | |
| 497 | + | .item_id | |
| 498 | + | .map(|id| id.to_string()) | |
| 499 | + | .unwrap_or_default(); | |
| 500 | + | let title = csv_escape(row.item_title.as_deref().unwrap_or("")); | |
| 501 | + | let email = csv_escape(row.buyer_email.as_deref().unwrap_or("")); | |
| 502 | + | println!( | |
| 503 | + | "{},{},{},{},{},{}", | |
| 504 | + | date, item_id, title, row.amount_cents, row.status, email | |
| 505 | + | ); | |
| 506 | + | } | |
| 507 | + | ||
| 508 | + | Ok(()) | |
| 509 | + | } | |
| 510 | + | ||
| 511 | + | /// Escape a value for CSV: wrap in quotes if it contains comma, quote, or newline. | |
| 512 | + | fn csv_escape(s: &str) -> String { | |
| 513 | + | if s.contains(',') || s.contains('"') || s.contains('\n') { | |
| 514 | + | format!("\"{}\"", s.replace('"', "\"\"")) | |
| 515 | + | } else { | |
| 516 | + | s.to_string() | |
| 517 | + | } | |
| 518 | + | } | |
| 519 | + | ||
| 520 | + | // ── Storage audit command ── | |
| 521 | + | ||
| 522 | + | async fn cmd_storage(pool: &PgPool, username_str: &str) -> anyhow::Result<()> { | |
| 523 | + | let username = Username::new(username_str) | |
| 524 | + | .map_err(|e| anyhow::anyhow!("invalid username: {e}"))?; | |
| 525 | + | ||
| 526 | + | let user = db::users::get_user_by_username(pool, &username) | |
| 527 | + | .await? | |
| 528 | + | .ok_or_else(|| anyhow::anyhow!("user '{}' not found", username_str))?; | |
| 529 | + | ||
| 530 | + | let item_keys = db::items::get_user_s3_keys(pool, user.id).await?; | |
| 531 | + | let version_keys = db::versions::get_user_version_s3_keys(pool, user.id).await?; | |
| 532 | + | ||
| 533 | + | if item_keys.is_empty() && version_keys.is_empty() { | |
| 534 | + | println!("No S3 files for '{}'.", username_str); | |
| 535 | + | return Ok(()); | |
| 536 | + | } | |
| 537 | + | ||
| 538 | + | println!( | |
| 539 | + | "{:<10} {:<20} {:<25} {}", | |
| 540 | + | "Type", "Project", "Item", "S3 Key" | |
| 541 | + | ); | |
| 542 | + | println!("{}", "-".repeat(100)); | |
| 543 | + | ||
| 544 | + | let mut item_file_count = 0u32; | |
| 545 | + | for row in &item_keys { | |
| 546 | + | if let Some(key) = &row.audio_s3_key { | |
| 547 | + | println!( | |
| 548 | + | "{:<10} {:<20} {:<25} {}", | |
| 549 | + | "audio", row.project_slug, row.title, key | |
| 550 | + | ); | |
| 551 | + | item_file_count += 1; | |
| 552 | + | } | |
| 553 | + | if let Some(key) = &row.cover_s3_key { | |
| 554 | + | println!( | |
| 555 | + | "{:<10} {:<20} {:<25} {}", | |
| 556 | + | "cover", row.project_slug, row.title, key | |
| 557 | + | ); | |
| 558 | + | item_file_count += 1; | |
| 559 | + | } | |
| 560 | + | } | |
| 561 | + | ||
| 562 | + | for row in &version_keys { | |
| 563 | + | if let Some(key) = &row.s3_key { | |
| 564 | + | let label = format!("{} v{}", row.item_title, row.version_number); | |
| 565 | + | let label_short = if label.len() > 23 { | |
| 566 | + | format!("{}...", &label[..20]) | |
| 567 | + | } else { | |
| 568 | + | label | |
| 569 | + | }; | |
| 570 | + | println!( | |
| 571 | + | "{:<10} {:<20} {:<25} {}", | |
| 572 | + | "version", row.project_slug, label_short, key | |
| 573 | + | ); | |
| 574 | + | } | |
| 575 | + | } | |
| 576 | + | ||
| 577 | + | let version_file_count = version_keys.iter().filter(|r| r.s3_key.is_some()).count(); | |
| 578 | + | println!( | |
| 579 | + | "\n{} item file(s), {} version file(s).", | |
| 580 | + | item_file_count, version_file_count | |
| 581 | + | ); | |
| 582 | + | Ok(()) | |
| 583 | + | } |
| @@ -127,7 +127,10 @@ pub async fn csrf_middleware(request: Request, next: Next) -> Response { | |||
| 127 | 127 | // - Stripe checkout is a vanilla form POST that redirects to Stripe's hosted page; | |
| 128 | 128 | // SameSite=Strict cookies prevent cross-origin CSRF, AuthUser is required, | |
| 129 | 129 | // and no state mutation occurs until Stripe's webhook confirms payment | |
| 130 | - | let exempt_paths = ["/stripe/webhook", "/stripe/checkout", "/stripe/subscribe", "/login", "/join", "/api/sync", "/oauth", "/auth/passkey", "/postmark/webhook", "/unsubscribe"]; | |
| 130 | + | // - /confirm-delete uses a signed HMAC link as its authorization; the user | |
| 131 | + | // arrives from an email and may not have an active session, so the | |
| 132 | + | // standard CSRF header cannot be attached to the vanilla form POST. | |
| 133 | + | let exempt_paths = ["/stripe/webhook", "/stripe/checkout", "/stripe/subscribe", "/login", "/join", "/api/sync", "/oauth", "/auth/passkey", "/postmark/webhook", "/unsubscribe", "/confirm-delete"]; | |
| 131 | 134 | ||
| 132 | 135 | if exempt_paths.iter().any(|p| path.starts_with(p)) { | |
| 133 | 136 | return next.run(request).await; |
| @@ -4,7 +4,7 @@ use chrono::{DateTime, Utc}; | |||
| 4 | 4 | use sqlx::PgPool; | |
| 5 | 5 | ||
| 6 | 6 | use super::models::*; | |
| 7 | - | use super::{LoginTokenId, UserId}; | |
| 7 | + | use super::UserId; | |
| 8 | 8 | use crate::error::Result; | |
| 9 | 9 | ||
| 10 | 10 | /// Increment failed login attempts for a user | |
| @@ -71,17 +71,24 @@ pub async fn create_login_token( | |||
| 71 | 71 | Ok(token) | |
| 72 | 72 | } | |
| 73 | 73 | ||
| 74 | - | /// Get a login token by hash (only if not used and not expired) | |
| 75 | - | pub async fn get_valid_login_token( | |
| 74 | + | /// Atomically consume a login token: mark it used and return it in one step. | |
| 75 | + | /// | |
| 76 | + | /// Returns `Some(token)` if the token was valid and successfully consumed, | |
| 77 | + | /// or `None` if the token was already used, expired, or does not exist. | |
| 78 | + | /// Because this is a single UPDATE with `used_at IS NULL` in the WHERE clause, | |
| 79 | + | /// concurrent requests for the same token will never both succeed. | |
| 80 | + | pub async fn consume_login_token( | |
| 76 | 81 | pool: &PgPool, | |
| 77 | 82 | token_hash: &str, | |
| 78 | 83 | ) -> Result<Option<DbLoginToken>> { | |
| 79 | 84 | let token = sqlx::query_as::<_, DbLoginToken>( | |
| 80 | 85 | r#" | |
| 81 | - | SELECT * FROM login_tokens | |
| 86 | + | UPDATE login_tokens | |
| 87 | + | SET used_at = NOW() | |
| 82 | 88 | WHERE token_hash = $1 | |
| 83 | 89 | AND used_at IS NULL | |
| 84 | 90 | AND expires_at > NOW() | |
| 91 | + | RETURNING * | |
| 85 | 92 | "#, | |
| 86 | 93 | ) | |
| 87 | 94 | .bind(token_hash) | |
| @@ -91,13 +98,3 @@ pub async fn get_valid_login_token( | |||
| 91 | 98 | Ok(token) | |
| 92 | 99 | } | |
| 93 | 100 | ||
| 94 | - | /// Mark a login token as used | |
| 95 | - | pub async fn mark_login_token_used(pool: &PgPool, token_id: LoginTokenId) -> Result<()> { | |
| 96 | - | sqlx::query("UPDATE login_tokens SET used_at = NOW() WHERE id = $1") | |
| 97 | - | .bind(token_id) | |
| 98 | - | .execute(pool) | |
| 99 | - | .await?; | |
| 100 | - | ||
| 101 | - | Ok(()) | |
| 102 | - | } | |
| 103 | - |
| @@ -102,12 +102,18 @@ pub async fn get_discount_code_by_seller_and_code( | |||
| 102 | 102 | /// reached its usage limit. The `WHERE` clause enforces the limit at the | |
| 103 | 103 | /// database level, preventing TOCTOU races where concurrent requests both | |
| 104 | 104 | /// read `use_count < max_uses` and both increment past the limit. | |
| 105 | - | pub async fn try_increment_discount_code_use_count(pool: &PgPool, id: DiscountCodeId) -> Result<bool> { | |
| 105 | + | /// | |
| 106 | + | /// Accepts any sqlx executor (`&PgPool`, `&mut Transaction`, etc.) so callers | |
| 107 | + | /// can include this in a larger transaction when needed. | |
| 108 | + | pub async fn try_increment_discount_code_use_count<'e>( | |
| 109 | + | executor: impl sqlx::PgExecutor<'e>, | |
| 110 | + | id: DiscountCodeId, | |
| 111 | + | ) -> Result<bool> { | |
| 106 | 112 | let result = sqlx::query( | |
| 107 | 113 | "UPDATE discount_codes SET use_count = use_count + 1 WHERE id = $1 AND (max_uses IS NULL OR use_count < max_uses)", | |
| 108 | 114 | ) | |
| 109 | 115 | .bind(id) | |
| 110 | - | .execute(pool) | |
| 116 | + | .execute(executor) | |
| 111 | 117 | .await?; | |
| 112 | 118 | ||
| 113 | 119 | Ok(result.rows_affected() > 0) |
| @@ -20,13 +20,3 @@ pub async fn add_suppression(pool: &PgPool, email: &str, reason: &str) -> Result | |||
| 20 | 20 | ||
| 21 | 21 | Ok(()) | |
| 22 | 22 | } | |
| 23 | - | ||
| 24 | - | /// Remove a suppression (admin un-suppression). | |
| 25 | - | pub async fn remove_suppression(pool: &PgPool, email: &str) -> Result<bool> { | |
| 26 | - | let result = sqlx::query("DELETE FROM email_suppressions WHERE LOWER(email) = LOWER($1)") | |
| 27 | - | .bind(email) | |
| 28 | - | .execute(pool) | |
| 29 | - | .await?; | |
| 30 | - | ||
| 31 | - | Ok(result.rows_affected() > 0) | |
| 32 | - | } |