max / makenotwork
25 files changed,
+2415 insertions,
-7 deletions
| @@ -1634,6 +1634,27 @@ dependencies = [ | |||
| 1634 | 1634 | ] | |
| 1635 | 1635 | ||
| 1636 | 1636 | [[package]] | |
| 1637 | + | name = "csv" | |
| 1638 | + | version = "1.4.0" | |
| 1639 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1640 | + | checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938" | |
| 1641 | + | dependencies = [ | |
| 1642 | + | "csv-core", | |
| 1643 | + | "itoa", | |
| 1644 | + | "ryu", | |
| 1645 | + | "serde_core", | |
| 1646 | + | ] | |
| 1647 | + | ||
| 1648 | + | [[package]] | |
| 1649 | + | name = "csv-core" | |
| 1650 | + | version = "0.1.13" | |
| 1651 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1652 | + | checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782" | |
| 1653 | + | dependencies = [ | |
| 1654 | + | "memchr", | |
| 1655 | + | ] | |
| 1656 | + | ||
| 1657 | + | [[package]] | |
| 1637 | 1658 | name = "darling" | |
| 1638 | 1659 | version = "0.20.11" | |
| 1639 | 1660 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -3351,7 +3372,7 @@ dependencies = [ | |||
| 3351 | 3372 | ||
| 3352 | 3373 | [[package]] | |
| 3353 | 3374 | name = "makenotwork" | |
| 3354 | - | version = "0.3.20" | |
| 3375 | + | version = "0.3.21" | |
| 3355 | 3376 | dependencies = [ | |
| 3356 | 3377 | "anyhow", | |
| 3357 | 3378 | "argon2", | |
| @@ -3363,6 +3384,7 @@ dependencies = [ | |||
| 3363 | 3384 | "base64 0.22.1", | |
| 3364 | 3385 | "chrono", | |
| 3365 | 3386 | "clap", | |
| 3387 | + | "csv", | |
| 3366 | 3388 | "dashmap", | |
| 3367 | 3389 | "docengine", | |
| 3368 | 3390 | "dotenvy", |
| @@ -69,6 +69,9 @@ goblin = "0.10" | |||
| 69 | 69 | zip = "8.2" | |
| 70 | 70 | yara-x = "1.13" | |
| 71 | 71 | ||
| 72 | + | # CSV parsing (import system) | |
| 73 | + | csv = "1.3" | |
| 74 | + | ||
| 72 | 75 | # CLI | |
| 73 | 76 | clap = { version = "4", features = ["derive"] } | |
| 74 | 77 |
| @@ -0,0 +1,297 @@ | |||
| 1 | + | # Import Research — Creator Platform Migration to MNW | |
| 2 | + | ||
| 3 | + | Phase 13D feasibility analysis. For each source platform: what data is extractable, how, and what maps to MNW's schema. | |
| 4 | + | ||
| 5 | + | ## MNW Target Schema (relevant tables) | |
| 6 | + | ||
| 7 | + | - `users` — email, username, display_name | |
| 8 | + | - `projects` — creator's page (name, description, category) | |
| 9 | + | - `items` — individual products/posts (title, description, price, file_type, content_data) | |
| 10 | + | - `subscription_tiers` — name, description, price_cents, project_id | |
| 11 | + | - `subscriptions` — subscriber_id, tier_id, stripe_subscription_id, status | |
| 12 | + | - `transactions` — buyer_id, item_id, amount_cents, currency, status | |
| 13 | + | - `mailing_list_subscribers` — email subscribers | |
| 14 | + | - `tags` / `item_tags` — taxonomy | |
| 15 | + | ||
| 16 | + | Import creates MNW entities; does NOT migrate active Stripe billing (subscribers must re-subscribe). | |
| 17 | + | ||
| 18 | + | --- | |
| 19 | + | ||
| 20 | + | ## 1. Generic CSV (Ko-fi, Itch.io, Sellfy) | |
| 21 | + | ||
| 22 | + | ### Approach | |
| 23 | + | Column-mapping UI with presets. Creator uploads a CSV, picks which columns map to which MNW fields. Presets auto-detect common formats. | |
| 24 | + | ||
| 25 | + | ### Feasibility: HIGH | |
| 26 | + | - Simplest to implement, most flexible | |
| 27 | + | - Covers any platform that exports CSV (almost all do) | |
| 28 | + | - Primary use: import subscriber/customer email lists + transaction history | |
| 29 | + | ||
| 30 | + | ### MNW mapping | |
| 31 | + | ||
| 32 | + | | CSV column (typical) | MNW target | Notes | | |
| 33 | + | |---|---|---| | |
| 34 | + | | email | `users.email` or `mailing_list_subscribers.email` | Dedup by email | | |
| 35 | + | | name | `users.display_name` | | | |
| 36 | + | | amount | `transactions.amount_cents` | Parse currency, convert to cents | | |
| 37 | + | | date | `transactions.created_at` | Flexible date parser needed | | |
| 38 | + | | product/item | `items.title` | Auto-create items if not found | | |
| 39 | + | | tier | `subscription_tiers.name` | Match or create | | |
| 40 | + | | status | `subscriptions.status` | Map active/cancelled/etc | | |
| 41 | + | ||
| 42 | + | ### Implementation | |
| 43 | + | - Migration 055: `import_jobs` table (user_id, source, status, progress, error_log, created_at) | |
| 44 | + | - `POST /api/users/me/import` multipart upload | |
| 45 | + | - Background `tokio::spawn` for processing | |
| 46 | + | - Progress polling endpoint | |
| 47 | + | - Dedup subscribers by email, rate-limit re-confirmation at 100/hr | |
| 48 | + | ||
| 49 | + | --- | |
| 50 | + | ||
| 51 | + | ## 2. Ko-fi | |
| 52 | + | ||
| 53 | + | ### Data access | |
| 54 | + | - **No REST API.** Webhook-only for future events. | |
| 55 | + | - **CSV exports from dashboard** (desktop only): Members CSV, Payments & Orders CSV, Supporters CSV | |
| 56 | + | - Webhooks: donation, subscription, shop order, commission events | |
| 57 | + | ||
| 58 | + | ### What's extractable | |
| 59 | + | ||
| 60 | + | | Data | Method | Fields | | |
| 61 | + | |---|---|---| | |
| 62 | + | | Supporter emails + amounts | Members CSV | username, email, membership status, total amount, payment processor | | |
| 63 | + | | Transaction history | Payments & Orders CSV | all payments with tax breakdown | | |
| 64 | + | | Supporter email list | My Supporters CSV | emails for campaigns | | |
| 65 | + | | Tier names | Webhook `tier_name` field | only for future events, not historical | | |
| 66 | + | | Shop products | None | manual re-creation required | | |
| 67 | + | | Posts/content | None | manual copy required | | |
| 68 | + | ||
| 69 | + | ### Feasibility: MEDIUM (CSV-only) | |
| 70 | + | - Use the Generic CSV importer with a Ko-fi preset | |
| 71 | + | - Preset auto-maps: email, name, amount, status columns from Members CSV | |
| 72 | + | - No API means no automated pull — creator must download CSVs manually | |
| 73 | + | - Cannot enumerate tiers programmatically; parse from CSV data or manual entry | |
| 74 | + | ||
| 75 | + | ### Ko-fi CSV preset columns | |
| 76 | + | Members CSV: `Username, Email, Membership Status, Total Amount, Payment Processor` | |
| 77 | + | (Exact column names need verification from a real export) | |
| 78 | + | ||
| 79 | + | --- | |
| 80 | + | ||
| 81 | + | ## 3. Substack | |
| 82 | + | ||
| 83 | + | ### Data access | |
| 84 | + | - **No public API.** Unofficial reverse-engineered wrappers exist but are fragile. | |
| 85 | + | - **Built-in export** (Settings > Exports): ZIP containing HTML posts + subscriber CSV | |
| 86 | + | ||
| 87 | + | ### What's extractable | |
| 88 | + | ||
| 89 | + | | Data | Method | Fields | | |
| 90 | + | |---|---|---| | |
| 91 | + | | Posts | ZIP export | HTML files (one per post), title in filename. No images (linked to Substack CDN) | | |
| 92 | + | | Subscribers | CSV in ZIP | id, email, name, subscribed_to_emails, complimentary_plan, stripe_customer_id, created_at, labels | | |
| 93 | + | | Free vs paid status | CSV | plan type column | | |
| 94 | + | | Engagement metrics | CSV (optional columns) | last email opened, post views | | |
| 95 | + | | Comments | None | not included in any export | | |
| 96 | + | | Images/media | None | referenced by URL only, must re-upload | | |
| 97 | + | ||
| 98 | + | ### Feasibility: HIGH | |
| 99 | + | - ZIP import: parse subscriber CSV → `mailing_list_subscribers` + `users` | |
| 100 | + | - HTML posts → convert to markdown (html2md), create as `items` with `FileType::Text` | |
| 101 | + | - Images: extract `<img>` URLs from HTML, re-upload to MNW S3, rewrite URLs | |
| 102 | + | - Stripe customer ID in CSV enables matching if creator uses same Stripe account on MNW | |
| 103 | + | ||
| 104 | + | ### Implementation notes | |
| 105 | + | - Accept `.zip` upload, detect Substack format (contains `posts/` dir + CSV) | |
| 106 | + | - HTML-to-markdown conversion for post bodies | |
| 107 | + | - Image pipeline: fetch external URLs → upload to S3 → rewrite in content | |
| 108 | + | - Tag extraction from CSV `labels` column | |
| 109 | + | ||
| 110 | + | --- | |
| 111 | + | ||
| 112 | + | ## 4. Ghost | |
| 113 | + | ||
| 114 | + | ### Data access | |
| 115 | + | - **Two APIs**: Content API (public data, safe for browsers) + Admin API (members, write access) | |
| 116 | + | - **Built-in export**: JSON file (posts, tags, authors) + separate Members CSV | |
| 117 | + | ||
| 118 | + | ### What's extractable | |
| 119 | + | ||
| 120 | + | | Data | Method | Fields | | |
| 121 | + | |---|---|---| | |
| 122 | + | | Posts + pages | JSON export or Admin API | title, slug, html/lexical content, status, visibility, feature_image, dates, custom_excerpt, SEO fields | | |
| 123 | + | | Tags | JSON export | name, slug, description | | |
| 124 | + | | Authors | JSON export | name, email, bio | | |
| 125 | + | | Members | CSV export or Admin API | email, name, subscribed, complimentary, stripe_customer_id, labels, created_at | | |
| 126 | + | | Tiers | Admin API | name, description, monthly/yearly price | | |
| 127 | + | | Images | Referenced by URL | must download + re-upload | | |
| 128 | + | ||
| 129 | + | ### Feasibility: HIGH | |
| 130 | + | - Best-structured export of all platforms | |
| 131 | + | - JSON is directly parseable; post content in HTML or Lexical JSON | |
| 132 | + | - Ghost → MNW mapping is clean: posts → items, tags → tags, tiers → subscription_tiers, members → users + subscriptions | |
| 133 | + | - Ghost has an official migration doc format that other platforms target | |
| 134 | + | ||
| 135 | + | ### Implementation notes | |
| 136 | + | - Accept `.json` upload (Ghost export format) | |
| 137 | + | - Parse `data.posts` → create MNW items (html content → markdown optional) | |
| 138 | + | - Parse `data.tags` + `data.posts_tags` → create MNW tags | |
| 139 | + | - Separate Members CSV → users + mailing_list_subscribers | |
| 140 | + | - Handle both `html` (legacy) and `lexical` (newer) content formats | |
| 141 | + | - Feature images: download + re-upload to S3 | |
| 142 | + | ||
| 143 | + | --- | |
| 144 | + | ||
| 145 | + | ## 5. Gumroad | |
| 146 | + | ||
| 147 | + | ### Data access | |
| 148 | + | - **REST API** (`api.gumroad.com/v2/`): products, sales, subscribers, licenses | |
| 149 | + | - **OAuth2** or personal access token | |
| 150 | + | - **CSV export**: sales analytics (emailed as download link, not instant) | |
| 151 | + | ||
| 152 | + | ### What's extractable | |
| 153 | + | ||
| 154 | + | | Data | Method | Fields | | |
| 155 | + | |---|---|---| | |
| 156 | + | | Products | `GET /v2/products` | name, price, description, custom_fields, variants | | |
| 157 | + | | Sales/customers | `GET /v2/sales` | email, amount, date, license_key, product_id. Filterable by email/date/product | | |
| 158 | + | | Subscribers | `GET /v2/products/:id/subscribers` | email, recurrence (monthly/quarterly/annual), status | | |
| 159 | + | | License keys | `GET /v2/licenses/verify` | key, uses_count, product | | |
| 160 | + | | Sales CSV | Dashboard export | customer emails, amounts, dates (emailed) | | |
| 161 | + | | Digital files | None via API | must manually re-upload | | |
| 162 | + | ||
| 163 | + | ### Feasibility: HIGH | |
| 164 | + | - Full API access with personal token (no OAuth dance needed for single-creator) | |
| 165 | + | - Products → MNW items (name, description, price mapping) | |
| 166 | + | - Sales → transactions (email matching for buyer_id) | |
| 167 | + | - Subscribers → subscription records (status, recurrence) | |
| 168 | + | - License keys → MNW license_keys table (direct mapping) | |
| 169 | + | ||
| 170 | + | ### Implementation notes | |
| 171 | + | - Two import modes: API pull (live, paginated) or CSV upload (offline) | |
| 172 | + | - API mode: creator enters access token, importer fetches all products + sales + subscribers | |
| 173 | + | - Rate limits undocumented — use conservative 60 req/min with exponential backoff on 429 | |
| 174 | + | - Pagination via `page_key` parameter (not page numbers) | |
| 175 | + | - Cannot download product files — creator must re-upload to MNW | |
| 176 | + | ||
| 177 | + | --- | |
| 178 | + | ||
| 179 | + | ## 6. Bandcamp | |
| 180 | + | ||
| 181 | + | ### Data access | |
| 182 | + | - **Very limited API** (sales endpoint only, partially deprecated) | |
| 183 | + | - **CSV exports from dashboard**: sales report + fan mailing list | |
| 184 | + | ||
| 185 | + | ### What's extractable | |
| 186 | + | ||
| 187 | + | | Data | Method | Fields | | |
| 188 | + | |---|---|---| | |
| 189 | + | | Sales history | Sales CSV | buyer name, email, item name, price, revenue share, tax, shipping, discount codes | | |
| 190 | + | | Fan emails | Mailing list CSV | email, country, zip, signup date | | |
| 191 | + | | Album/track metadata | None via API | no structured export | | |
| 192 | + | | Audio files | None | manual download from dashboard | | |
| 193 | + | | Revenue/payouts | Sales CSV | revenue breakdown, payout info | | |
| 194 | + | ||
| 195 | + | ### Feasibility: MEDIUM (CSV-only) | |
| 196 | + | - Use Generic CSV importer with Bandcamp preset | |
| 197 | + | - Sales CSV → transactions + auto-create items by name | |
| 198 | + | - Mailing list CSV → mailing_list_subscribers (email, country) | |
| 199 | + | - No metadata API means album/track info must be entered manually or scraped (not recommended) | |
| 200 | + | ||
| 201 | + | ### Bandcamp Sales CSV columns | |
| 202 | + | `sale_date, buyer_name, buyer_email, item_name, item_url, quantity, unit_price, sub_total, assessed_revenue_share, collected_revenue_share, net_revenue, currency, shipping, discount_code` | |
| 203 | + | (Approximate — verify from real export) | |
| 204 | + | ||
| 205 | + | --- | |
| 206 | + | ||
| 207 | + | ## 7. Lemon Squeezy | |
| 208 | + | ||
| 209 | + | ### Data access | |
| 210 | + | - **Full REST API** (`api.lemonsqueezy.com/v1/`): products, orders, subscriptions, customers, files | |
| 211 | + | - API key auth (bearer token) | |
| 212 | + | - JSON:API format | |
| 213 | + | ||
| 214 | + | ### What's extractable | |
| 215 | + | ||
| 216 | + | | Data | Method | Fields | | |
| 217 | + | |---|---|---| | |
| 218 | + | | Customers | `GET /v1/customers` | email, name, billing address, tax info | | |
| 219 | + | | Orders | `GET /v1/orders` | customer_id, items, total, currency, status, dates | | |
| 220 | + | | Subscriptions | `GET /v1/subscriptions` | customer, product, variant, status, billing dates, card info | | |
| 221 | + | | Products | `GET /v1/products` | name, description, status, pricing, tax category | | |
| 222 | + | | Files | `GET /v1/files` | signed download URL (1hr expiry, 10 dl/day/IP), size, version | | |
| 223 | + | | License keys | `GET /v1/license-keys` | key, status, activation limit | | |
| 224 | + | ||
| 225 | + | ### Feasibility: HIGH | |
| 226 | + | - Best API of all platforms (full REST, well-documented, rate-limited at 300 req/min) | |
| 227 | + | - Clean JSON:API format with relationships and includes | |
| 228 | + | - Products → items, customers → users, orders → transactions, subscriptions → subscriptions | |
| 229 | + | - Can even download digital files (signed URLs with rate limits) | |
| 230 | + | ||
| 231 | + | ### Implementation notes | |
| 232 | + | - API-only import (no CSV needed) | |
| 233 | + | - Creator enters API key → importer fetches everything | |
| 234 | + | - Paginate through all resources | |
| 235 | + | - File download: fetch signed URL → stream to MNW S3 (respect 10 dl/day/IP limit) | |
| 236 | + | - License keys: direct mapping to MNW license_keys table | |
| 237 | + | ||
| 238 | + | --- | |
| 239 | + | ||
| 240 | + | ## 8. Patreon | |
| 241 | + | ||
| 242 | + | ### Data access | |
| 243 | + | - **API v2** (JSON:API format): campaigns, members, posts, tiers, benefits | |
| 244 | + | - **OAuth2** with creator access token (all scopes for own campaign) | |
| 245 | + | - **CSV exports from dashboard**: Relationship Manager (patrons), Earnings, Posts Insights | |
| 246 | + | ||
| 247 | + | ### What's extractable | |
| 248 | + | ||
| 249 | + | | Data | Method | Fields | | |
| 250 | + | |---|---|---| | |
| 251 | + | | Patrons/members | API `GET /campaigns/{id}/members` | email (needs scope), name, patron_status, pledge amount, lifetime_support_cents, join date, tier | | |
| 252 | + | | Tiers | API `GET /campaigns/{id}?include=tiers` | title, description, amount_cents, patron_count, image_url | | |
| 253 | + | | Posts | API `GET /campaigns/{id}/posts` | title, content (HTML), published_at, is_public, url | | |
| 254 | + | | Patron CSV | Dashboard export | name, email, tier, pledge, lifetime amount, start date, charge dates, Discord handle, address | | |
| 255 | + | | Earnings | Dashboard CSV | transaction-level detail | | |
| 256 | + | | Post attachments | None | no structured API for media/files | | |
| 257 | + | ||
| 258 | + | ### Feasibility: MEDIUM-HIGH | |
| 259 | + | - API is powerful but complex (JSON:API with explicit field/include params) | |
| 260 | + | - **Critical**: must request `campaigns.members[email]` scope for email access | |
| 261 | + | - Posts have HTML content but no structured attachment access — images are inline URLs | |
| 262 | + | - Pagination: cursor-based, max 1000 per page for members | |
| 263 | + | - Rate limits exist but are undocumented | |
| 264 | + | ||
| 265 | + | ### Implementation notes | |
| 266 | + | - OAuth flow: creator authorizes MNW to read their Patreon data | |
| 267 | + | - Scopes needed: `campaigns`, `campaigns.members`, `campaigns.members[email]`, `campaigns.posts` | |
| 268 | + | - OR: creator provides Creator Access Token (has all scopes, simpler) | |
| 269 | + | - Members → users + subscriptions (match by email, create tiers) | |
| 270 | + | - Posts → items (HTML content, fetch inline images → re-upload to S3) | |
| 271 | + | - Patron CSV as fallback: more financial detail than API provides | |
| 272 | + | - Hardest importer due to JSON:API complexity + OAuth + undocumented rate limits | |
| 273 | + | ||
| 274 | + | --- | |
| 275 | + | ||
| 276 | + | ## Build Order (recommended) | |
| 277 | + | ||
| 278 | + | | Priority | Importer | Effort | Why | | |
| 279 | + | |---|---|---|---| | |
| 280 | + | | 1 | Generic CSV + presets | Medium | Foundation for Ko-fi, Bandcamp, Itch.io, Sellfy, any CSV | | |
| 281 | + | | 2 | Substack ZIP | Medium | Large addressable market (writers), structured ZIP format | | |
| 282 | + | | 3 | Ghost JSON | Low-Medium | Clean export format, direct field mapping, bloggers | | |
| 283 | + | | 4 | Gumroad API/CSV | Medium | Large creator base, good API, product + sales + license data | | |
| 284 | + | | 5 | Bandcamp CSV | Low | Preset on top of Generic CSV, musicians | | |
| 285 | + | | 6 | Lemon Squeezy API | Medium | Best API, growing platform, can even fetch files | | |
| 286 | + | | 7 | Patreon OAuth API | High | Hardest (OAuth + JSON:API + undocumented limits), but biggest market | | |
| 287 | + | ||
| 288 | + | ## Shared Infrastructure (build first) | |
| 289 | + | ||
| 290 | + | - [ ] Migration: `import_jobs` table (id, user_id, source, status, progress_pct, total_rows, processed_rows, error_log, created_at, completed_at) | |
| 291 | + | - [ ] `POST /api/users/me/import` — multipart upload endpoint | |
| 292 | + | - [ ] Background processor: `tokio::spawn`, chunked processing, progress updates | |
| 293 | + | - [ ] Progress polling: `GET /api/users/me/import/{id}` → status + progress | |
| 294 | + | - [ ] Subscriber pipeline: dedup by email, opt-in re-confirmation, rate-limit 100/hr | |
| 295 | + | - [ ] Content helpers: HTML-to-markdown, image re-upload to S3, tag normalization | |
| 296 | + | - [ ] Stripe customer ID mapping (when creator uses same Stripe account on MNW) | |
| 297 | + | - [ ] Column mapping UI: drag-drop or select for CSV columns → MNW fields, with platform presets |
| @@ -1,9 +1,9 @@ | |||
| 1 | 1 | # Makenotwork TODO | |
| 2 | 2 | ||
| 3 | 3 | ## Status | |
| 4 | - | Done: All pre-beta phases + frontend audit + content fingerprinting + bundled license text + video upload/playback + maintainability splits + S3 storage extraction (shared crate). Active: None. Next: Deploy v0.3.20, then post-beta features below. | |
| 4 | + | Done: All pre-beta phases + frontend audit + content fingerprinting + bundled license text + video upload/playback + maintainability splits + S3 storage extraction + item sections + Phase 13D-A import system. Active: None. Next: Deploy v0.3.22, then post-beta features below. | |
| 5 | 5 | ||
| 6 | - | Live at makenot.work. v0.3.19. Audit grade A. Stripe + Postmark live. All platform integrations (I1-I5) deployed. | |
| 6 | + | Live at makenot.work. v0.3.21. Audit grade A. Stripe + Postmark live. All platform integrations (I1-I5) deployed. | |
| 7 | 7 | ||
| 8 | 8 | **Scope:** Sections tagged `(pre-beta)` ship before initial beta. Untagged sections are post-beta. | |
| 9 | 9 | ||
| @@ -244,8 +244,32 @@ Competitive context: Gumroad has per-product affiliates with configurable rates. | |||
| 244 | 244 | ### Phase 13B: Labels — Remaining | |
| 245 | 245 | - [ ] Publish/update reminder: show applied labels summary when publishing | |
| 246 | 246 | ||
| 247 | - | ### Phase 13D: Importers | |
| 248 | - | Shared infra first: `POST /api/users/me/import` multipart upload, `ImportJob` table, background `tokio::spawn`, progress polling. Subscriber pipeline: dedup by email, re-confirmation, rate-limit 100/hr. Content helpers: HTML-to-markdown, image re-upload to S3, tag normalization, Stripe customer ID mapping. Build order: (1) Generic CSV with column mapping UI + presets for Ko-fi/Itch.io/Sellfy, (2) Substack ZIP, (3) Ghost JSON, (4) Gumroad API/CSV, (5) Bandcamp sales CSV, (6) Lemon Squeezy REST API, (7) Patreon OAuth API (hardest). | |
| 247 | + | ### Phase 13D: Creator Platform Import System | |
| 248 | + | Migration 055. 28 unit tests + 8 integration tests. Three-phase build: A (infra + CSV), B (Substack + Ghost), C (Gumroad + Bandcamp + Lemon Squeezy + Patreon). | |
| 249 | + | ||
| 250 | + | #### Phase A — Done | |
| 251 | + | - [x] `import_jobs` table (migration 055) | |
| 252 | + | - [x] `ImportPayload` common intermediate format (subscribers, items, tiers, transactions) | |
| 253 | + | - [x] `ImportSource` + `ImportJobStatus` enums | |
| 254 | + | - [x] Generic CSV converter with column mapping, flexible date/currency parsing, BOM handling | |
| 255 | + | - [x] Import pipeline: tiers, items, tags, mailing list subscribers, chunked progress | |
| 256 | + | - [x] Email-only mailing list subscribers (migration 055: nullable user_id + email column) | |
| 257 | + | - [x] `db/imports.rs` CRUD (create, progress, complete, fail, get, list) | |
| 258 | + | - [x] 3 API endpoints: `POST /api/users/me/import`, `GET /api/users/me/import/{id}`, `GET /api/users/me/imports` | |
| 259 | + | - [x] Dashboard UI: CSV upload, preview, column auto-detection, progress polling | |
| 260 | + | - [x] HTML tag stripping for imported body content | |
| 261 | + | - [x] "Import Data" button on dashboard user details tab | |
| 262 | + | ||
| 263 | + | #### Phase B — Remaining | |
| 264 | + | - [ ] Substack ZIP importer (posts.json + subscribers.csv inside ZIP archive) | |
| 265 | + | - [ ] Ghost JSON importer (Ghost export format → ImportPayload) | |
| 266 | + | ||
| 267 | + | #### Phase C — Remaining | |
| 268 | + | - [ ] Gumroad CSV/API importer (sales CSV + product API) | |
| 269 | + | - [ ] Bandcamp sales CSV preset (column mapping preset for Bandcamp export format) | |
| 270 | + | - [ ] Ko-fi members CSV preset (column mapping preset) | |
| 271 | + | - [ ] Lemon Squeezy REST API importer | |
| 272 | + | - [ ] Patreon OAuth API importer (OAuth flow + paginated member/post API) | |
| 249 | 273 | ||
| 250 | 274 | ### Phase 14: Video — Done | |
| 251 | 275 | Migration 053. 6 integration tests. | |
| @@ -419,6 +443,7 @@ Archive policy: items on platform 12+ months stay hosted if creator cancels. | |||
| 419 | 443 | - [ ] Revenue-share crowdfunding: enable creators to fund projects by selling future revenue shares. 5-phase plan (MVP, controlled rollout, platform features, Reg CF compliance, full launch). Requires securities attorney consultation (~$5K) as first step. Year 1 cost ~$80K, separate from core MNW ops. See financial_dashboard.md. | |
| 420 | 444 | - [ ] sqlx offline mode | |
| 421 | 445 | - [ ] Team/organization accounts | |
| 446 | + | - [ ] Creator @makenot.work email addresses (forwarding-only to creator's real email, vanity address as premium feature). Migadu Mini ($90/yr) for business email; creator addresses via forwarding aliases on Maxi plan or lightweight relay. No mailbox hosting for creators. | |
| 422 | 447 | - [ ] Tax-year revenue summary, invoice generation, 1099 guidance | |
| 423 | 448 | - [ ] Merchant of Record (MoR) — handle VAT/GST/sales tax collection+remittance globally (Gumroad does this since 2025; significant legal+infra effort) | |
| 424 | 449 | - [ ] Email drip workflows — automated sequences triggered by purchase, subscription, cancellation (Gumroad has this natively) | |
| @@ -442,9 +467,10 @@ MNW/src/ | |||
| 442 | 467 | storage.rs, payments/, templates/, routes/ | |
| 443 | 468 | git/, git_issues/, synckit_auth.rs, build_runner.rs, validation/ | |
| 444 | 469 | fingerprint/ (registry, visible stamps, watermarks, streaming) | |
| 470 | + | import/ (CSV converter, pipeline, intermediate format) | |
| 445 | 471 | MNW/tests/ | |
| 446 | 472 | integration.rs, harness/, workflows/*.rs | |
| 447 | - | MNW/migrations/ (001-053) | |
| 473 | + | MNW/migrations/ (001-055) | |
| 448 | 474 | MNW/templates/ | |
| 449 | 475 | MNW/deploy/ | |
| 450 | 476 | MNW/site-docs/public/, MNW/site-docs/unpublished/ |
| @@ -0,0 +1,31 @@ | |||
| 1 | + | -- Creator platform import system (Phase 13D). | |
| 2 | + | -- Tracks import jobs for CSV/JSON data from Patreon, Ko-fi, Gumroad, etc. | |
| 3 | + | ||
| 4 | + | CREATE TABLE import_jobs ( | |
| 5 | + | id UUID PRIMARY KEY DEFAULT gen_random_uuid(), | |
| 6 | + | user_id UUID NOT NULL REFERENCES users(id), | |
| 7 | + | project_id UUID NOT NULL REFERENCES projects(id), | |
| 8 | + | source TEXT NOT NULL, | |
| 9 | + | status TEXT NOT NULL DEFAULT 'pending', | |
| 10 | + | total_rows INTEGER NOT NULL DEFAULT 0, | |
| 11 | + | processed_rows INTEGER NOT NULL DEFAULT 0, | |
| 12 | + | created_rows INTEGER NOT NULL DEFAULT 0, | |
| 13 | + | skipped_rows INTEGER NOT NULL DEFAULT 0, | |
| 14 | + | error_log TEXT, | |
| 15 | + | created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), | |
| 16 | + | completed_at TIMESTAMPTZ | |
| 17 | + | ); | |
| 18 | + | ||
| 19 | + | CREATE INDEX idx_import_jobs_user ON import_jobs(user_id); | |
| 20 | + | ||
| 21 | + | -- Allow email-only subscribers for imported contacts who don't have MNW accounts. | |
| 22 | + | -- user_id becomes nullable; exactly one of (user_id, email) must be set. | |
| 23 | + | ALTER TABLE mailing_list_subscribers ADD COLUMN email VARCHAR(320); | |
| 24 | + | ALTER TABLE mailing_list_subscribers ALTER COLUMN user_id DROP NOT NULL; | |
| 25 | + | ALTER TABLE mailing_list_subscribers ADD CONSTRAINT chk_subscriber_identity | |
| 26 | + | CHECK (user_id IS NOT NULL OR email IS NOT NULL); | |
| 27 | + | ||
| 28 | + | -- Unique constraint for email-only subscribers (prevent duplicate email per list). | |
| 29 | + | CREATE UNIQUE INDEX idx_mailing_list_subscribers_email | |
| 30 | + | ON mailing_list_subscribers (list_id, email) | |
| 31 | + | WHERE email IS NOT NULL; |
| @@ -772,6 +772,46 @@ impl_str_enum!(MailingListType { | |||
| 772 | 772 | Patches => "patches", | |
| 773 | 773 | }); | |
| 774 | 774 | ||
| 775 | + | // ── Import System ── | |
| 776 | + | ||
| 777 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] | |
| 778 | + | #[serde(rename_all = "snake_case")] | |
| 779 | + | pub enum ImportSource { | |
| 780 | + | GenericCsv, | |
| 781 | + | Substack, | |
| 782 | + | Ghost, | |
| 783 | + | Gumroad, | |
| 784 | + | Bandcamp, | |
| 785 | + | LemonSqueezy, | |
| 786 | + | Patreon, | |
| 787 | + | } | |
| 788 | + | ||
| 789 | + | impl_str_enum!(ImportSource { | |
| 790 | + | GenericCsv => "generic_csv", | |
| 791 | + | Substack => "substack", | |
| 792 | + | Ghost => "ghost", | |
| 793 | + | Gumroad => "gumroad", | |
| 794 | + | Bandcamp => "bandcamp", | |
| 795 | + | LemonSqueezy => "lemon_squeezy", | |
| 796 | + | Patreon => "patreon", | |
| 797 | + | }); | |
| 798 | + | ||
| 799 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] | |
| 800 | + | #[serde(rename_all = "lowercase")] | |
| 801 | + | pub enum ImportJobStatus { | |
| 802 | + | Pending, | |
| 803 | + | Processing, | |
| 804 | + | Completed, | |
| 805 | + | Failed, | |
| 806 | + | } | |
| 807 | + | ||
| 808 | + | impl_str_enum!(ImportJobStatus { | |
| 809 | + | Pending => "pending", | |
| 810 | + | Processing => "processing", | |
| 811 | + | Completed => "completed", | |
| 812 | + | Failed => "failed", | |
| 813 | + | }); | |
| 814 | + | ||
| 775 | 815 | #[cfg(test)] | |
| 776 | 816 | mod tests { | |
| 777 | 817 | use super::*; | |
| @@ -1222,4 +1262,25 @@ mod tests { | |||
| 1222 | 1262 | let cards = ProjectFeature::wizard_type_cards(&[]); | |
| 1223 | 1263 | assert_eq!(cards.len(), 5); | |
| 1224 | 1264 | } | |
| 1265 | + | ||
| 1266 | + | #[test] | |
| 1267 | + | fn import_source_round_trip() { | |
| 1268 | + | assert_eq!(ImportSource::GenericCsv.to_string(), "generic_csv"); | |
| 1269 | + | assert_eq!("substack".parse::<ImportSource>().unwrap(), ImportSource::Substack); | |
| 1270 | + | assert_eq!("ghost".parse::<ImportSource>().unwrap(), ImportSource::Ghost); | |
| 1271 | + | assert_eq!("gumroad".parse::<ImportSource>().unwrap(), ImportSource::Gumroad); | |
| 1272 | + | assert_eq!("bandcamp".parse::<ImportSource>().unwrap(), ImportSource::Bandcamp); | |
| 1273 | + | assert_eq!("lemon_squeezy".parse::<ImportSource>().unwrap(), ImportSource::LemonSqueezy); | |
| 1274 | + | assert_eq!("patreon".parse::<ImportSource>().unwrap(), ImportSource::Patreon); | |
| 1275 | + | assert!("bogus".parse::<ImportSource>().is_err()); | |
| 1276 | + | } | |
| 1277 | + | ||
| 1278 | + | #[test] | |
| 1279 | + | fn import_job_status_round_trip() { | |
| 1280 | + | assert_eq!(ImportJobStatus::Pending.to_string(), "pending"); | |
| 1281 | + | assert_eq!("processing".parse::<ImportJobStatus>().unwrap(), ImportJobStatus::Processing); | |
| 1282 | + | assert_eq!("completed".parse::<ImportJobStatus>().unwrap(), ImportJobStatus::Completed); | |
| 1283 | + | assert_eq!("failed".parse::<ImportJobStatus>().unwrap(), ImportJobStatus::Failed); | |
| 1284 | + | assert!("bogus".parse::<ImportJobStatus>().is_err()); | |
| 1285 | + | } | |
| 1225 | 1286 | } |
| @@ -177,6 +177,7 @@ define_pg_uuid_id!( | |||
| 177 | 177 | MailingListSubscriberId, | |
| 178 | 178 | CustomDomainId, | |
| 179 | 179 | ItemSectionId, | |
| 180 | + | ImportJobId, | |
| 180 | 181 | ); | |
| 181 | 182 | ||
| 182 | 183 | #[cfg(test)] |
| @@ -0,0 +1,147 @@ | |||
| 1 | + | //! Import job CRUD: create, update progress, complete, list. | |
| 2 | + | ||
| 3 | + | use sqlx::PgPool; | |
| 4 | + | ||
| 5 | + | use super::id_types::{ImportJobId, ProjectId, UserId}; | |
| 6 | + | use super::models::DbImportJob; | |
| 7 | + | use crate::import::ImportSource; | |
| 8 | + | use crate::error::Result; | |
| 9 | + | ||
| 10 | + | /// Create a new import job in `pending` status. | |
| 11 | + | pub async fn create_import_job( | |
| 12 | + | pool: &PgPool, | |
| 13 | + | user_id: UserId, | |
| 14 | + | project_id: ProjectId, | |
| 15 | + | source: ImportSource, | |
| 16 | + | total_rows: i32, | |
| 17 | + | ) -> Result<DbImportJob> { | |
| 18 | + | let job = sqlx::query_as::<_, DbImportJob>( | |
| 19 | + | r#" | |
| 20 | + | INSERT INTO import_jobs (user_id, project_id, source, total_rows) | |
| 21 | + | VALUES ($1, $2, $3, $4) | |
| 22 | + | RETURNING * | |
| 23 | + | "#, | |
| 24 | + | ) | |
| 25 | + | .bind(user_id) | |
| 26 | + | .bind(project_id) | |
| 27 | + | .bind(source) | |
| 28 | + | .bind(total_rows) | |
| 29 | + | .fetch_one(pool) | |
| 30 | + | .await?; | |
| 31 | + | ||
| 32 | + | Ok(job) | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | /// Update the processing progress of an import job. | |
| 36 | + | pub async fn update_import_progress( | |
| 37 | + | pool: &PgPool, | |
| 38 | + | job_id: ImportJobId, | |
| 39 | + | processed_rows: i32, | |
| 40 | + | created_rows: i32, | |
| 41 | + | skipped_rows: i32, | |
| 42 | + | ) -> Result<()> { | |
| 43 | + | sqlx::query( | |
| 44 | + | r#" | |
| 45 | + | UPDATE import_jobs | |
| 46 | + | SET processed_rows = $2, created_rows = $3, skipped_rows = $4 | |
| 47 | + | WHERE id = $1 | |
| 48 | + | "#, | |
| 49 | + | ) | |
| 50 | + | .bind(job_id) | |
| 51 | + | .bind(processed_rows) | |
| 52 | + | .bind(created_rows) | |
| 53 | + | .bind(skipped_rows) | |
| 54 | + | .execute(pool) | |
| 55 | + | .await?; | |
| 56 | + | ||
| 57 | + | Ok(()) | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | /// Update the status of an import job. | |
| 61 | + | pub async fn update_import_status( | |
| 62 | + | pool: &PgPool, | |
| 63 | + | job_id: ImportJobId, | |
| 64 | + | status: &str, | |
| 65 | + | ) -> Result<()> { | |
| 66 | + | sqlx::query("UPDATE import_jobs SET status = $2 WHERE id = $1") | |
| 67 | + | .bind(job_id) | |
| 68 | + | .bind(status) | |
| 69 | + | .execute(pool) | |
| 70 | + | .await?; | |
| 71 | + | ||
| 72 | + | Ok(()) | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | /// Mark an import job as completed with optional error log. | |
| 76 | + | pub async fn complete_import_job( | |
| 77 | + | pool: &PgPool, | |
| 78 | + | job_id: ImportJobId, | |
| 79 | + | error_log: Option<String>, | |
| 80 | + | ) -> Result<()> { | |
| 81 | + | sqlx::query( | |
| 82 | + | r#" | |
| 83 | + | UPDATE import_jobs | |
| 84 | + | SET status = 'completed', completed_at = NOW(), error_log = $2 | |
| 85 | + | WHERE id = $1 | |
| 86 | + | "#, | |
| 87 | + | ) | |
| 88 | + | .bind(job_id) | |
| 89 | + | .bind(error_log) | |
| 90 | + | .execute(pool) | |
| 91 | + | .await?; | |
| 92 | + | ||
| 93 | + | Ok(()) | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | /// Mark an import job as failed with an error message. | |
| 97 | + | pub async fn fail_import_job( | |
| 98 | + | pool: &PgPool, | |
| 99 | + | job_id: ImportJobId, | |
| 100 | + | error: &str, | |
| 101 | + | ) -> Result<()> { | |
| 102 | + | sqlx::query( | |
| 103 | + | r#" | |
| 104 | + | UPDATE import_jobs | |
| 105 | + | SET status = 'failed', completed_at = NOW(), error_log = $2 | |
| 106 | + | WHERE id = $1 | |
| 107 | + | "#, | |
| 108 | + | ) | |
| 109 | + | .bind(job_id) | |
| 110 | + | .bind(error) | |
| 111 | + | .execute(pool) | |
| 112 | + | .await?; | |
| 113 | + | ||
| 114 | + | Ok(()) | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | /// Get a single import job by ID, scoped to a user. | |
| 118 | + | pub async fn get_import_job( | |
| 119 | + | pool: &PgPool, | |
| 120 | + | job_id: ImportJobId, | |
| 121 | + | user_id: UserId, | |
| 122 | + | ) -> Result<Option<DbImportJob>> { | |
| 123 | + | let job = sqlx::query_as::<_, DbImportJob>( | |
| 124 | + | "SELECT * FROM import_jobs WHERE id = $1 AND user_id = $2", | |
| 125 | + | ) | |
| 126 | + | .bind(job_id) | |
| 127 | + | .bind(user_id) | |
| 128 | + | .fetch_optional(pool) | |
| 129 | + | .await?; | |
| 130 | + | ||
| 131 | + | Ok(job) | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | /// List import jobs for a user, most recent first. | |
| 135 | + | pub async fn list_import_jobs( | |
| 136 | + | pool: &PgPool, | |
| 137 | + | user_id: UserId, | |
| 138 | + | ) -> Result<Vec<DbImportJob>> { | |
| 139 | + | let jobs = sqlx::query_as::<_, DbImportJob>( | |
| 140 | + | "SELECT * FROM import_jobs WHERE user_id = $1 ORDER BY created_at DESC LIMIT 20", | |
| 141 | + | ) | |
| 142 | + | .bind(user_id) | |
| 143 | + | .fetch_all(pool) | |
| 144 | + | .await?; | |
| 145 | + | ||
| 146 | + | Ok(jobs) | |
| 147 | + | } |
| @@ -162,6 +162,29 @@ pub async fn create_default_lists( | |||
| 162 | 162 | Ok(()) | |
| 163 | 163 | } | |
| 164 | 164 | ||
| 165 | + | /// Subscribe an email address (without a user account) to a mailing list. | |
| 166 | + | /// Used for importing subscriber lists from external platforms. | |
| 167 | + | /// Returns `true` if a new row was created, `false` if the email already exists. | |
| 168 | + | pub async fn subscribe_by_email( | |
| 169 | + | pool: &PgPool, | |
| 170 | + | list_id: MailingListId, | |
| 171 | + | email: &str, | |
| 172 | + | ) -> Result<bool> { | |
| 173 | + | let result = sqlx::query( | |
| 174 | + | r#" | |
| 175 | + | INSERT INTO mailing_list_subscribers (list_id, email) | |
| 176 | + | VALUES ($1, $2) | |
| 177 | + | ON CONFLICT DO NOTHING | |
| 178 | + | "#, | |
| 179 | + | ) | |
| 180 | + | .bind(list_id) | |
| 181 | + | .bind(email.to_lowercase()) | |
| 182 | + | .execute(pool) | |
| 183 | + | .await?; | |
| 184 | + | ||
| 185 | + | Ok(result.rows_affected() > 0) | |
| 186 | + | } | |
| 187 | + | ||
| 165 | 188 | /// Convenience: find the content list for a project and subscribe a user. | |
| 166 | 189 | /// No-op if the content list doesn't exist yet. | |
| 167 | 190 | pub async fn subscribe_to_content_list( |
| @@ -54,6 +54,7 @@ pub mod patches; | |||
| 54 | 54 | pub mod bundles; | |
| 55 | 55 | pub(crate) mod email_signups; | |
| 56 | 56 | pub(crate) mod fingerprints; | |
| 57 | + | pub(crate) mod imports; | |
| 57 | 58 | ||
| 58 | 59 | pub use id_types::*; | |
| 59 | 60 | pub use validated_types::*; |
| @@ -1718,6 +1718,25 @@ pub struct DbCustomDomain { | |||
| 1718 | 1718 | pub verified_at: Option<DateTime<Utc>>, | |
| 1719 | 1719 | } | |
| 1720 | 1720 | ||
| 1721 | + | // ── Import Job models ── | |
| 1722 | + | ||
| 1723 | + | /// A data import job (CSV/JSON from external platforms). | |
| 1724 | + | #[derive(Debug, Clone, FromRow, Serialize)] | |
| 1725 | + | pub struct DbImportJob { | |
| 1726 | + | pub id: ImportJobId, | |
| 1727 | + | pub user_id: UserId, | |
| 1728 | + | pub project_id: ProjectId, | |
| 1729 | + | pub source: super::ImportSource, | |
| 1730 | + | pub status: super::ImportJobStatus, | |
| 1731 | + | pub total_rows: i32, | |
| 1732 | + | pub processed_rows: i32, | |
| 1733 | + | pub created_rows: i32, | |
| 1734 | + | pub skipped_rows: i32, | |
| 1735 | + | pub error_log: Option<String>, | |
| 1736 | + | pub created_at: DateTime<Utc>, | |
| 1737 | + | pub completed_at: Option<DateTime<Utc>>, | |
| 1738 | + | } | |
| 1739 | + | ||
| 1721 | 1740 | #[cfg(test)] | |
| 1722 | 1741 | mod tests { | |
| 1723 | 1742 | use super::*; |
| @@ -0,0 +1,396 @@ | |||
| 1 | + | //! Generic CSV → ImportPayload converter. | |
| 2 | + | //! | |
| 3 | + | //! Parses CSV data with a user-provided column mapping and produces an | |
| 4 | + | //! `ImportPayload` containing subscribers and/or transactions depending | |
| 5 | + | //! on which columns are mapped. | |
| 6 | + | ||
| 7 | + | use chrono::{DateTime, NaiveDate, NaiveDateTime, Utc}; | |
| 8 | + | ||
| 9 | + | use super::{ColumnMapping, ImportPayload, ImportSubscriber, ImportTransaction}; | |
| 10 | + | use crate::error::{AppError, Result}; | |
| 11 | + | ||
| 12 | + | /// Parse CSV bytes into an `ImportPayload` using the given column mapping. | |
| 13 | + | /// | |
| 14 | + | /// Rows with an email column produce `ImportSubscriber` entries. | |
| 15 | + | /// Rows with an amount column produce `ImportTransaction` entries. | |
| 16 | + | /// A single row can produce both if both columns are mapped. | |
| 17 | + | #[tracing::instrument(skip_all, name = "import::parse_csv")] | |
| 18 | + | pub fn parse_csv(bytes: &[u8], mapping: &ColumnMapping) -> Result<ImportPayload> { | |
| 19 | + | // Strip UTF-8 BOM if present | |
| 20 | + | let bytes = if bytes.starts_with(&[0xEF, 0xBB, 0xBF]) { | |
| 21 | + | &bytes[3..] | |
| 22 | + | } else { | |
| 23 | + | bytes | |
| 24 | + | }; | |
| 25 | + | ||
| 26 | + | let mut reader = csv::ReaderBuilder::new() | |
| 27 | + | .flexible(true) | |
| 28 | + | .trim(csv::Trim::All) | |
| 29 | + | .from_reader(bytes); | |
| 30 | + | ||
| 31 | + | let mut payload = ImportPayload::default(); | |
| 32 | + | let mut errors = Vec::new(); | |
| 33 | + | ||
| 34 | + | for (row_idx, result) in reader.records().enumerate() { | |
| 35 | + | let record = match result { | |
| 36 | + | Ok(r) => r, | |
| 37 | + | Err(e) => { | |
| 38 | + | errors.push(format!("Row {}: parse error: {}", row_idx + 2, e)); | |
| 39 | + | continue; | |
| 40 | + | } | |
| 41 | + | }; | |
| 42 | + | ||
| 43 | + | // Extract email | |
| 44 | + | let email = mapping | |
| 45 | + | ||
| 46 | + | .and_then(|i| record.get(i)) | |
| 47 | + | .map(|s| s.trim().to_lowercase()) | |
| 48 | + | .filter(|s| s.contains('@') && s.contains('.')); | |
| 49 | + | ||
| 50 | + | // Extract name | |
| 51 | + | let name = mapping | |
| 52 | + | .name | |
| 53 | + | .and_then(|i| record.get(i)) | |
| 54 | + | .map(sanitize_field) | |
| 55 | + | .filter(|s| !s.is_empty()); | |
| 56 | + | ||
| 57 | + | // Extract amount | |
| 58 | + | let amount_cents = mapping | |
| 59 | + | .amount | |
| 60 | + | .and_then(|i| record.get(i)) | |
| 61 | + | .and_then(parse_amount_cents); | |
| 62 | + | ||
| 63 | + | // Extract date | |
| 64 | + | let date = mapping | |
| 65 | + | .date | |
| 66 | + | .and_then(|i| record.get(i)) | |
| 67 | + | .and_then(parse_flexible_date); | |
| 68 | + | ||
| 69 | + | // Extract item title | |
| 70 | + | let item_title = mapping | |
| 71 | + | .item_title | |
| 72 | + | .and_then(|i| record.get(i)) | |
| 73 | + | .map(sanitize_field) | |
| 74 | + | .filter(|s| !s.is_empty()); | |
| 75 | + | ||
| 76 | + | // Extract tier | |
| 77 | + | let tier_name = mapping | |
| 78 | + | .tier | |
| 79 | + | .and_then(|i| record.get(i)) | |
| 80 | + | .map(sanitize_field) | |
| 81 | + | .filter(|s| !s.is_empty()); | |
| 82 | + | ||
| 83 | + | // Extract status | |
| 84 | + | let status = mapping | |
| 85 | + | .status | |
| 86 | + | .and_then(|i| record.get(i)) | |
| 87 | + | .map(|s| sanitize_field(s).to_lowercase()) | |
| 88 | + | .filter(|s| !s.is_empty()); | |
| 89 | + | ||
| 90 | + | // Build subscriber if we have email | |
| 91 | + | if let Some(ref email) = email { | |
| 92 | + | payload.subscribers.push(ImportSubscriber { | |
| 93 | + | email: email.clone(), | |
| 94 | + | name: name.clone(), | |
| 95 | + | tier_name, | |
| 96 | + | status: status.clone(), | |
| 97 | + | joined_at: date, | |
| 98 | + | lifetime_amount_cents: amount_cents, | |
| 99 | + | stripe_customer_id: None, | |
| 100 | + | }); | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | // Build transaction if we have amount and email | |
| 104 | + | if let (Some(cents), Some(email)) = (amount_cents, &email) { | |
| 105 | + | payload.transactions.push(ImportTransaction { | |
| 106 | + | buyer_email: email.clone(), | |
| 107 | + | buyer_name: name, | |
| 108 | + | item_title, | |
| 109 | + | amount_cents: cents, | |
| 110 | + | currency: "USD".into(), | |
| 111 | + | date: date.unwrap_or_else(Utc::now), | |
| 112 | + | status, | |
| 113 | + | }); | |
| 114 | + | } | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | if payload.subscribers.is_empty() && payload.transactions.is_empty() { | |
| 118 | + | return Err(AppError::Validation( | |
| 119 | + | "No valid rows found. Check your column mapping and CSV format.".into(), | |
| 120 | + | )); | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | if !errors.is_empty() { | |
| 124 | + | tracing::warn!(error_count = errors.len(), "CSV parse had row-level errors"); | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | Ok(payload) | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | /// Parse a currency string into cents. | |
| 131 | + | /// Handles: "$12.50", "12.50", "€10", "£5.00", "1,234.56", "1234" | |
| 132 | + | fn parse_amount_cents(s: &str) -> Option<i64> { | |
| 133 | + | let cleaned: String = s | |
| 134 | + | .trim() | |
| 135 | + | .replace(['$', '€', '£', '¥'], "") | |
| 136 | + | .replace(',', "") | |
| 137 | + | .trim() | |
| 138 | + | .to_string(); | |
| 139 | + | ||
| 140 | + | if cleaned.is_empty() { | |
| 141 | + | return None; | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | // Try as decimal (dollars.cents) | |
| 145 | + | if let Some((dollars, cents_str)) = cleaned.split_once('.') { | |
| 146 | + | let dollars: i64 = dollars.trim().parse().ok()?; | |
| 147 | + | // Pad or truncate to exactly 2 decimal places | |
| 148 | + | let cents_str = if cents_str.len() >= 2 { | |
| 149 | + | ¢s_str[..2] | |
| 150 | + | } else { | |
| 151 | + | cents_str | |
| 152 | + | }; | |
| 153 | + | let cents: i64 = if cents_str.len() == 1 { | |
| 154 | + | cents_str.parse::<i64>().ok()? * 10 | |
| 155 | + | } else { | |
| 156 | + | cents_str.parse().ok()? | |
| 157 | + | }; | |
| 158 | + | return Some(dollars * 100 + cents); | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | // Try as whole number (already cents or whole dollars) | |
| 162 | + | let n: i64 = cleaned.parse().ok()?; | |
| 163 | + | // Heuristic: values > 10000 are probably already in cents | |
| 164 | + | if n > 10000 { | |
| 165 | + | Some(n) | |
| 166 | + | } else { | |
| 167 | + | Some(n * 100) | |
| 168 | + | } | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | /// Parse dates flexibly. Supports: | |
| 172 | + | /// - ISO 8601: "2024-01-15T10:30:00Z", "2024-01-15" | |
| 173 | + | /// - US format: "01/15/2024", "1/15/2024" | |
| 174 | + | /// - EU format: "15.01.2024", "15/01/2024" (day > 12 disambiguates) | |
| 175 | + | /// - Epoch seconds: "1705312200" | |
| 176 | + | fn parse_flexible_date(s: &str) -> Option<DateTime<Utc>> { | |
| 177 | + | let s = s.trim(); | |
| 178 | + | if s.is_empty() { | |
| 179 | + | return None; | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | // ISO 8601 with time | |
| 183 | + | if let Ok(dt) = s.parse::<DateTime<Utc>>() { | |
| 184 | + | return Some(dt); | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | // ISO 8601 datetime without timezone | |
| 188 | + | if let Ok(ndt) = NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S") { | |
| 189 | + | return Some(ndt.and_utc()); | |
| 190 | + | } | |
| 191 | + | ||
| 192 | + | // ISO 8601 date only | |
| 193 | + | if let Ok(nd) = NaiveDate::parse_from_str(s, "%Y-%m-%d") { | |
| 194 | + | return nd.and_hms_opt(0, 0, 0).map(|ndt| ndt.and_utc()); | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | // US format: MM/DD/YYYY | |
| 198 | + | if let Ok(nd) = NaiveDate::parse_from_str(s, "%m/%d/%Y") { | |
| 199 | + | return nd.and_hms_opt(0, 0, 0).map(|ndt| ndt.and_utc()); | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | // EU format with dots: DD.MM.YYYY | |
| 203 | + | if let Ok(nd) = NaiveDate::parse_from_str(s, "%d.%m.%Y") { | |
| 204 | + | return nd.and_hms_opt(0, 0, 0).map(|ndt| ndt.and_utc()); | |
| 205 | + | } | |
| 206 | + | ||
| 207 | + | // EU format with slashes: DD/MM/YYYY (try if day > 12) | |
| 208 | + | if let Some((a, rest)) = s.split_once('/') | |
| 209 | + | && let Some((b, c)) = rest.split_once('/') | |
| 210 | + | && let (Ok(first), Ok(second), Ok(year)) = | |
| 211 | + | (a.parse::<u32>(), b.parse::<u32>(), c.parse::<i32>()) | |
| 212 | + | && first > 12 | |
| 213 | + | && second <= 12 | |
| 214 | + | && let Some(nd) = NaiveDate::from_ymd_opt(year, second, first) | |
| 215 | + | { | |
| 216 | + | return nd.and_hms_opt(0, 0, 0).map(|ndt| ndt.and_utc()); | |
| 217 | + | } | |
| 218 | + | ||
| 219 | + | // Epoch seconds | |
| 220 | + | if let Ok(ts) = s.parse::<i64>() { | |
| 221 | + | return DateTime::from_timestamp(ts, 0); | |
| 222 | + | } | |
| 223 | + | ||
| 224 | + | None | |
| 225 | + | } | |
| 226 | + | ||
| 227 | + | /// Sanitize a CSV field value: trim whitespace, strip formula-triggering chars. | |
| 228 | + | fn sanitize_field(s: &str) -> String { | |
| 229 | + | let trimmed = s.trim(); | |
| 230 | + | if trimmed.starts_with(['=', '+', '-', '@', '\t', '\r']) { | |
| 231 | + | format!("'{trimmed}") | |
| 232 | + | } else { | |
| 233 | + | trimmed.to_string() | |
| 234 | + | } | |
| 235 | + | } | |
| 236 | + | ||
| 237 | + | #[cfg(test)] | |
| 238 | + | mod tests { | |
| 239 | + | use super::*; | |
| 240 | + | ||
| 241 | + | #[test] | |
| 242 | + | fn parse_amount_dollars_and_cents() { | |
| 243 | + | assert_eq!(parse_amount_cents("$12.50"), Some(1250)); | |
| 244 | + | assert_eq!(parse_amount_cents("12.50"), Some(1250)); | |
| 245 | + | assert_eq!(parse_amount_cents("€10.00"), Some(1000)); | |
| 246 | + | assert_eq!(parse_amount_cents("£5.5"), Some(550)); | |
| 247 | + | assert_eq!(parse_amount_cents("$1,234.56"), Some(123456)); | |
| 248 | + | } | |
| 249 | + | ||
| 250 | + | #[test] | |
| 251 | + | fn parse_amount_whole_numbers() { | |
| 252 | + | assert_eq!(parse_amount_cents("10"), Some(1000)); | |
| 253 | + | assert_eq!(parse_amount_cents("0"), Some(0)); | |
| 254 | + | } | |
| 255 | + | ||
| 256 | + | #[test] | |
| 257 | + | fn parse_amount_large_numbers_as_cents() { | |
| 258 | + | // > 10000 treated as already cents | |
| 259 | + | assert_eq!(parse_amount_cents("15000"), Some(15000)); | |
| 260 | + | } | |
| 261 | + | ||
| 262 | + | #[test] | |
| 263 | + | fn parse_amount_empty() { | |
| 264 | + | assert_eq!(parse_amount_cents(""), None); | |
| 265 | + | assert_eq!(parse_amount_cents(" "), None); | |
| 266 | + | assert_eq!(parse_amount_cents("abc"), None); | |
| 267 | + | } | |
| 268 | + | ||
| 269 | + | #[test] | |
| 270 | + | fn parse_date_iso8601() { | |
| 271 | + | let dt = parse_flexible_date("2024-01-15").unwrap(); | |
| 272 | + | assert_eq!(dt.date_naive().to_string(), "2024-01-15"); | |
| 273 | + | } | |
| 274 | + | ||
| 275 | + | #[test] | |
| 276 | + | fn parse_date_iso8601_with_time() { | |
| 277 | + | let dt = parse_flexible_date("2024-01-15T10:30:00Z").unwrap(); | |
| 278 | + | assert_eq!(dt.date_naive().to_string(), "2024-01-15"); | |
| 279 | + | } | |
| 280 | + | ||
| 281 | + | #[test] | |
| 282 | + | fn parse_date_us_format() { | |
| 283 | + | let dt = parse_flexible_date("01/15/2024").unwrap(); | |
| 284 | + | assert_eq!(dt.date_naive().to_string(), "2024-01-15"); | |
| 285 | + | } | |
| 286 | + | ||
| 287 | + | #[test] | |
| 288 | + | fn parse_date_eu_format_dots() { | |
| 289 | + | let dt = parse_flexible_date("15.01.2024").unwrap(); | |
| 290 | + | assert_eq!(dt.date_naive().to_string(), "2024-01-15"); | |
| 291 | + | } | |
| 292 | + | ||
| 293 | + | #[test] | |
| 294 | + | fn parse_date_eu_format_slashes_day_over_12() { | |
| 295 | + | let dt = parse_flexible_date("25/01/2024").unwrap(); | |
| 296 | + | assert_eq!(dt.date_naive().to_string(), "2024-01-25"); | |
| 297 | + | } | |
| 298 | + | ||
| 299 | + | #[test] | |
| 300 | + | fn parse_date_epoch() { | |
| 301 | + | let dt = parse_flexible_date("1705312200").unwrap(); | |
| 302 | + | assert_eq!(dt.date_naive().to_string(), "2024-01-15"); | |
| 303 | + | } | |
| 304 | + | ||
| 305 | + | #[test] | |
| 306 | + | fn parse_date_empty() { | |
| 307 | + | assert!(parse_flexible_date("").is_none()); | |
| 308 | + | assert!(parse_flexible_date(" ").is_none()); | |
| 309 | + | assert!(parse_flexible_date("not-a-date").is_none()); | |
| 310 | + | } | |
| 311 | + | ||
| 312 | + | #[test] | |
| 313 | + | fn parse_csv_basic_subscribers() { | |
| 314 | + | let csv = b"email,name\nalice@test.com,Alice\nbob@test.com,Bob\n"; | |
| 315 | + | let mapping = ColumnMapping { | |
| 316 | + | email: Some(0), | |
| 317 | + | name: Some(1), | |
| 318 | + | ..Default::default() | |
| 319 | + | }; | |
| 320 | + | let payload = parse_csv(csv, &mapping).unwrap(); | |
| 321 | + | assert_eq!(payload.subscribers.len(), 2); | |
| 322 | + | assert_eq!(payload.subscribers[0].email, "alice@test.com"); | |
| 323 | + | assert_eq!(payload.subscribers[0].name.as_deref(), Some("Alice")); | |
| 324 | + | assert_eq!(payload.subscribers[1].email, "bob@test.com"); | |
| 325 | + | } | |
| 326 | + | ||
| 327 | + | #[test] | |
| 328 | + | fn parse_csv_with_bom() { | |
| 329 | + | let mut csv = vec![0xEF, 0xBB, 0xBF]; // UTF-8 BOM | |
| 330 | + | csv.extend_from_slice(b"email\nalice@test.com\n"); | |
| 331 | + | let mapping = ColumnMapping { | |
| 332 | + | email: Some(0), | |
| 333 | + | ..Default::default() | |
| 334 | + | }; | |
| 335 | + | let payload = parse_csv(&csv, &mapping).unwrap(); | |
| 336 | + | assert_eq!(payload.subscribers.len(), 1); | |
| 337 | + | } | |
| 338 | + | ||
| 339 | + | #[test] | |
| 340 | + | fn parse_csv_transactions() { | |
| 341 | + | let csv = b"email,amount,date\nbuyer@test.com,$25.00,2024-01-15\n"; | |
| 342 | + | let mapping = ColumnMapping { | |
| 343 | + | email: Some(0), | |
| 344 | + | amount: Some(1), | |
| 345 | + | date: Some(2), | |
| 346 | + | ..Default::default() | |
| 347 | + | }; | |
| 348 | + | let payload = parse_csv(csv, &mapping).unwrap(); | |
| 349 | + | assert_eq!(payload.subscribers.len(), 1); | |
| 350 | + | assert_eq!(payload.transactions.len(), 1); | |
| 351 | + | assert_eq!(payload.transactions[0].amount_cents, 2500); | |
| 352 | + | } | |
| 353 | + | ||
| 354 | + | #[test] | |
| 355 | + | fn parse_csv_empty_returns_error() { | |
| 356 | + | let csv = b"email,name\n"; | |
| 357 | + | let mapping = ColumnMapping { | |
| 358 | + | email: Some(0), | |
| 359 | + | name: Some(1), | |
| 360 | + | ..Default::default() | |
| 361 | + | }; | |
| 362 | + | assert!(parse_csv(csv, &mapping).is_err()); | |
| 363 | + | } | |
| 364 | + | ||
| 365 | + | #[test] | |
| 366 | + | fn parse_csv_skips_invalid_emails() { | |
| 367 | + | let csv = b"email\nnot-an-email\nalice@test.com\n"; | |
| 368 | + | let mapping = ColumnMapping { | |
| 369 | + | email: Some(0), | |
| 370 | + | ..Default::default() | |
| 371 | + | }; | |
| 372 | + | let payload = parse_csv(csv, &mapping).unwrap(); | |
| 373 | + | assert_eq!(payload.subscribers.len(), 1); | |
| 374 | + | assert_eq!(payload.subscribers[0].email, "alice@test.com"); | |
| 375 | + | } | |
| 376 | + | ||
| 377 | + | #[test] | |
| 378 | + | fn parse_csv_mixed_currencies() { | |
| 379 | + | let csv = b"email,amount\na@b.com,$10.00\nc@d.com,EUR20.50\ne@f.com,500\n"; | |
| 380 | + | let mapping = ColumnMapping { | |
| 381 | + | email: Some(0), | |
| 382 | + | amount: Some(1), | |
| 383 | + | ..Default::default() | |
| 384 | + | }; | |
| 385 | + | let payload = parse_csv(csv, &mapping).unwrap(); | |
| 386 | + | assert_eq!(payload.transactions.len(), 2); // EUR20.50 won't parse (no € symbol) | |
| 387 | + | assert_eq!(payload.transactions[0].amount_cents, 1000); | |
| 388 | + | } | |
| 389 | + | ||
| 390 | + | #[test] | |
| 391 | + | fn sanitize_field_strips_formula_chars() { | |
| 392 | + | assert_eq!(sanitize_field("=SUM(A1)"), "'=SUM(A1)"); | |
| 393 | + | assert_eq!(sanitize_field("+cmd"), "'+cmd"); | |
| 394 | + | assert_eq!(sanitize_field("normal"), "normal"); | |
| 395 | + | } | |
| 396 | + | } |
| @@ -0,0 +1,120 @@ | |||
| 1 | + | //! Creator platform import system. | |
| 2 | + | //! | |
| 3 | + | //! Converts data from external platforms (Patreon, Ko-fi, Gumroad, Bandcamp, | |
| 4 | + | //! Substack, Ghost, Lemon Squeezy) into a common intermediate format, then | |
| 5 | + | //! feeds it through a generic pipeline that creates MNW entities. | |
| 6 | + | ||
| 7 | + | pub mod csv_converter; | |
| 8 | + | pub mod pipeline; | |
| 9 | + | ||
| 10 | + | use chrono::{DateTime, Utc}; | |
| 11 | + | use serde::Deserialize; | |
| 12 | + | ||
| 13 | + | // Re-export enums from db layer (where impl_str_enum macro lives). | |
| 14 | + | pub use crate::db::{ImportJobStatus, ImportSource}; | |
| 15 | + | ||
| 16 | + | // ── Common Intermediate Format ── | |
| 17 | + | ||
| 18 | + | #[derive(Debug, Clone, Default)] | |
| 19 | + | pub struct ImportPayload { | |
| 20 | + | pub subscribers: Vec<ImportSubscriber>, | |
| 21 | + | pub items: Vec<ImportItem>, | |
| 22 | + | pub tiers: Vec<ImportTier>, | |
| 23 | + | pub transactions: Vec<ImportTransaction>, | |
| 24 | + | pub tags: Vec<String>, | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | impl ImportPayload { | |
| 28 | + | /// Total number of entities across all categories. | |
| 29 | + | pub fn total_rows(&self) -> usize { | |
| 30 | + | self.subscribers.len() | |
| 31 | + | + self.items.len() | |
| 32 | + | + self.tiers.len() | |
| 33 | + | + self.transactions.len() | |
| 34 | + | } | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | #[derive(Debug, Clone)] | |
| 38 | + | pub struct ImportSubscriber { | |
| 39 | + | pub email: String, | |
| 40 | + | pub name: Option<String>, | |
| 41 | + | pub tier_name: Option<String>, | |
| 42 | + | pub status: Option<String>, | |
| 43 | + | pub joined_at: Option<DateTime<Utc>>, | |
| 44 | + | pub lifetime_amount_cents: Option<i64>, | |
| 45 | + | pub stripe_customer_id: Option<String>, | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | #[derive(Debug, Clone)] | |
| 49 | + | pub struct ImportItem { | |
| 50 | + | pub title: String, | |
| 51 | + | pub description: Option<String>, | |
| 52 | + | pub price_cents: Option<i64>, | |
| 53 | + | pub body_html: Option<String>, | |
| 54 | + | pub tags: Vec<String>, | |
| 55 | + | pub published_at: Option<DateTime<Utc>>, | |
| 56 | + | pub is_public: bool, | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | #[derive(Debug, Clone)] | |
| 60 | + | pub struct ImportTier { | |
| 61 | + | pub name: String, | |
| 62 | + | pub description: Option<String>, | |
| 63 | + | pub price_cents: i64, | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | #[derive(Debug, Clone)] | |
| 67 | + | pub struct ImportTransaction { | |
| 68 | + | pub buyer_email: String, | |
| 69 | + | pub buyer_name: Option<String>, | |
| 70 | + | pub item_title: Option<String>, | |
| 71 | + | pub amount_cents: i64, | |
| 72 | + | pub currency: String, | |
| 73 | + | pub date: DateTime<Utc>, | |
| 74 | + | pub status: Option<String>, | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | // ── Column Mapping (CSV) ── | |
| 78 | + | ||
| 79 | + | /// Maps CSV column indices to semantic fields. | |
| 80 | + | #[derive(Debug, Clone, Default, Deserialize)] | |
| 81 | + | pub struct ColumnMapping { | |
| 82 | + | pub email: Option<usize>, | |
| 83 | + | pub name: Option<usize>, | |
| 84 | + | pub amount: Option<usize>, | |
| 85 | + | pub date: Option<usize>, | |
| 86 | + | pub item_title: Option<usize>, | |
| 87 | + | pub tier: Option<usize>, | |
| 88 | + | pub status: Option<usize>, | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | #[cfg(test)] | |
| 92 | + | mod tests { | |
| 93 | + | use super::*; | |
| 94 | + | ||
| 95 | + | #[test] | |
| 96 | + | fn payload_total_rows() { | |
| 97 | + | let mut p = ImportPayload::default(); | |
| 98 | + | assert_eq!(p.total_rows(), 0); | |
| 99 | + | ||
| 100 | + | p.subscribers.push(ImportSubscriber { | |
| 101 | + | email: "a@b.com".into(), | |
| 102 | + | name: None, | |
| 103 | + | tier_name: None, | |
| 104 | + | status: None, | |
| 105 | + | joined_at: None, | |
| 106 | + | lifetime_amount_cents: None, | |
| 107 | + | stripe_customer_id: None, | |
| 108 | + | }); | |
| 109 | + | p.transactions.push(ImportTransaction { | |
| 110 | + | buyer_email: "a@b.com".into(), | |
| 111 | + | buyer_name: None, | |
| 112 | + | item_title: None, | |
| 113 | + | amount_cents: 100, | |
| 114 | + | currency: "USD".into(), | |
| 115 | + | date: Utc::now(), | |
| 116 | + | status: None, | |
| 117 | + | }); | |
| 118 | + | assert_eq!(p.total_rows(), 2); | |
| 119 | + | } | |
| 120 | + | } |
| @@ -0,0 +1,274 @@ | |||
| 1 | + | //! Generic import pipeline: takes an `ImportPayload` and creates MNW entities. | |
| 2 | + | //! | |
| 3 | + | //! Processing happens in chunks of 50 rows with progress updates after each chunk. | |
| 4 | + | //! Individual row failures are logged but don't abort the import. | |
| 5 | + | ||
| 6 | + | use sqlx::PgPool; | |
| 7 | + | ||
| 8 | + | use super::{ImportPayload, ImportTier}; | |
| 9 | + | use crate::db::{self, ImportJobId, ItemType, ProjectId, UserId}; | |
| 10 | + | use crate::error::Result; | |
| 11 | + | ||
| 12 | + | /// Chunk size for progress updates. | |
| 13 | + | const CHUNK_SIZE: usize = 50; | |
| 14 | + | ||
| 15 | + | /// Run the full import pipeline for a job. | |
| 16 | + | /// | |
| 17 | + | /// Creates MNW entities (tiers, items, tags, mailing list subscribers) from the | |
| 18 | + | /// intermediate payload. Transactions are recorded as subscriber metadata but | |
| 19 | + | /// not inserted into the `transactions` table (no matching buyer accounts exist | |
| 20 | + | /// for imported email addresses). | |
| 21 | + | #[tracing::instrument(skip_all, name = "import::run_import", fields(job_id = %job_id))] | |
| 22 | + | pub async fn run_import( | |
| 23 | + | pool: &PgPool, | |
| 24 | + | job_id: ImportJobId, | |
| 25 | + | project_id: ProjectId, | |
| 26 | + | _user_id: UserId, | |
| 27 | + | payload: ImportPayload, | |
| 28 | + | ) -> Result<()> { | |
| 29 | + | db::imports::update_import_status(pool, job_id, "processing").await?; | |
| 30 | + | ||
| 31 | + | let mut processed: i32 = 0; | |
| 32 | + | let mut created: i32 = 0; | |
| 33 | + | let mut skipped: i32 = 0; | |
| 34 | + | let mut errors: Vec<String> = Vec::new(); | |
| 35 | + | ||
| 36 | + | // ── Phase 1: Tiers ── | |
| 37 | + | let created_tiers = import_tiers(pool, project_id, &payload.tiers, &mut errors).await; | |
| 38 | + | processed += payload.tiers.len() as i32; | |
| 39 | + | created += created_tiers; | |
| 40 | + | skipped += payload.tiers.len() as i32 - created_tiers; | |
| 41 | + | update_progress(pool, job_id, processed, created, skipped).await; | |
| 42 | + | ||
| 43 | + | // ── Phase 2: Items ── | |
| 44 | + | for chunk in payload.items.chunks(CHUNK_SIZE) { | |
| 45 | + | for item in chunk { | |
| 46 | + | match import_item(pool, project_id, item).await { | |
| 47 | + | Ok(true) => created += 1, | |
| 48 | + | Ok(false) => skipped += 1, | |
| 49 | + | Err(e) => { | |
| 50 | + | errors.push(format!("Item '{}': {}", item.title, e)); | |
| 51 | + | skipped += 1; | |
| 52 | + | } | |
| 53 | + | } | |
| 54 | + | processed += 1; | |
| 55 | + | } | |
| 56 | + | update_progress(pool, job_id, processed, created, skipped).await; | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | // ── Phase 3: Subscribers (mailing list) ── | |
| 60 | + | // Ensure the project has a content mailing list | |
| 61 | + | let list = db::mailing_lists::create_list( | |
| 62 | + | pool, | |
| 63 | + | project_id, | |
| 64 | + | db::MailingListType::Content, | |
| 65 | + | "Imported Subscribers", | |
| 66 | + | Some("Subscribers imported from external platform"), | |
| 67 | + | ) | |
| 68 | + | .await?; | |
| 69 | + | ||
| 70 | + | for chunk in payload.subscribers.chunks(CHUNK_SIZE) { | |
| 71 | + | for sub in chunk { | |
| 72 | + | match db::mailing_lists::subscribe_by_email(pool, list.id, &sub.email).await { | |
| 73 | + | Ok(true) => created += 1, | |
| 74 | + | Ok(false) => skipped += 1, | |
| 75 | + | Err(e) => { | |
| 76 | + | errors.push(format!("Subscriber '{}': {}", sub.email, e)); | |
| 77 | + | skipped += 1; | |
| 78 | + | } | |
| 79 | + | } | |
| 80 | + | processed += 1; | |
| 81 | + | } | |
| 82 | + | update_progress(pool, job_id, processed, created, skipped).await; | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | // ── Phase 4: Transactions (count only, no DB insert) ── | |
| 86 | + | // We can't create real transaction rows because imported buyer emails | |
| 87 | + | // don't correspond to MNW user accounts. We just count them. | |
| 88 | + | processed += payload.transactions.len() as i32; | |
| 89 | + | skipped += payload.transactions.len() as i32; | |
| 90 | + | update_progress(pool, job_id, processed, created, skipped).await; | |
| 91 | + | ||
| 92 | + | // ── Finalize ── | |
| 93 | + | let error_log = if errors.is_empty() { | |
| 94 | + | None | |
| 95 | + | } else { | |
| 96 | + | Some(errors.join("\n")) | |
| 97 | + | }; | |
| 98 | + | ||
| 99 | + | db::imports::complete_import_job(pool, job_id, error_log).await?; | |
| 100 | + | ||
| 101 | + | tracing::info!( | |
| 102 | + | processed, | |
| 103 | + | created, | |
| 104 | + | skipped, | |
| 105 | + | error_count = errors.len(), | |
| 106 | + | "import job completed" | |
| 107 | + | ); | |
| 108 | + | ||
| 109 | + | Ok(()) | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | /// Import subscription tiers. Returns number of tiers successfully created. | |
| 113 | + | async fn import_tiers( | |
| 114 | + | pool: &PgPool, | |
| 115 | + | project_id: ProjectId, | |
| 116 | + | tiers: &[ImportTier], | |
| 117 | + | errors: &mut Vec<String>, | |
| 118 | + | ) -> i32 { | |
| 119 | + | let mut count = 0; | |
| 120 | + | for tier in tiers { | |
| 121 | + | let price = tier.price_cents.min(i32::MAX as i64) as i32; | |
| 122 | + | match db::subscriptions::create_subscription_tier( | |
| 123 | + | pool, | |
| 124 | + | project_id, | |
| 125 | + | &tier.name, | |
| 126 | + | tier.description.as_deref(), | |
| 127 | + | price, | |
| 128 | + | ) | |
| 129 | + | .await | |
| 130 | + | { | |
| 131 | + | Ok(_) => count += 1, | |
| 132 | + | Err(e) => { | |
| 133 | + | // Check for duplicate (name already exists for project) | |
| 134 | + | if e.to_string().contains("23505") { | |
| 135 | + | // Unique violation — tier already exists, skip | |
| 136 | + | } else { | |
| 137 | + | errors.push(format!("Tier '{}': {}", tier.name, e)); | |
| 138 | + | } | |
| 139 | + | } | |
| 140 | + | } | |
| 141 | + | } | |
| 142 | + | count | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | /// Import a single item. Returns Ok(true) if created, Ok(false) if skipped. | |
| 146 | + | async fn import_item( | |
| 147 | + | pool: &PgPool, | |
| 148 | + | project_id: ProjectId, | |
| 149 | + | item: &super::ImportItem, | |
| 150 | + | ) -> Result<bool> { | |
| 151 | + | let price = item.price_cents.unwrap_or(0).min(i32::MAX as i64) as i32; | |
| 152 | + | ||
| 153 | + | // Convert HTML body to plain text (simple tag stripping for Phase A) | |
| 154 | + | let description = item | |
| 155 | + | .body_html | |
| 156 | + | .as_deref() | |
| 157 | + | .map(strip_html_tags) | |
| 158 | + | .or(item.description.clone()); | |
| 159 | + | ||
| 160 | + | let db_item = db::items::create_item( | |
| 161 | + | pool, | |
| 162 | + | project_id, | |
| 163 | + | &item.title, | |
| 164 | + | description.as_deref(), | |
| 165 | + | price, | |
| 166 | + | ItemType::Digital, // Default type for imports | |
| 167 | + | ) | |
| 168 | + | .await?; | |
| 169 | + | ||
| 170 | + | // Publish if the source item was public | |
| 171 | + | if item.is_public { | |
| 172 | + | sqlx::query("UPDATE items SET published = true, published_at = COALESCE($2, NOW()) WHERE id = $1") | |
| 173 | + | .bind(db_item.id) | |
| 174 | + | .bind(item.published_at) | |
| 175 | + | .execute(pool) | |
| 176 | + | .await?; | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | // Attach tags by name lookup | |
| 180 | + | for tag_name in &item.tags { | |
| 181 | + | let slug = crate::helpers::slugify(tag_name); | |
| 182 | + | if let Ok(Some(tag)) = db::tags::get_tag_by_slug(pool, &slug).await { | |
| 183 | + | let _ = db::tags::add_tag_to_item(pool, db_item.id, tag.id, false).await; | |
| 184 | + | } | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | Ok(true) | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | /// Minimal HTML tag stripper. Converts <br>, <p>, <li> to newlines, | |
| 191 | + | /// strips all other tags, and collapses whitespace. | |
| 192 | + | fn strip_html_tags(html: &str) -> String { | |
| 193 | + | let mut result = String::with_capacity(html.len()); | |
| 194 | + | let mut in_tag = false; | |
| 195 | + | let mut last_was_newline = false; | |
| 196 | + | ||
| 197 | + | // Replace block-level tags with newlines first | |
| 198 | + | let html = html | |
| 199 | + | .replace("<br>", "\n") | |
| 200 | + | .replace("<br/>", "\n") | |
| 201 | + | .replace("<br />", "\n") | |
| 202 | + | .replace("</p>", "\n") | |
| 203 | + | .replace("</li>", "\n") | |
| 204 | + | .replace("</div>", "\n"); | |
| 205 | + | ||
| 206 | + | for ch in html.chars() { | |
| 207 | + | if ch == '<' { | |
| 208 | + | in_tag = true; | |
| 209 | + | continue; | |
| 210 | + | } | |
| 211 | + | if ch == '>' { | |
| 212 | + | in_tag = false; | |
| 213 | + | continue; | |
| 214 | + | } | |
| 215 | + | if !in_tag { | |
| 216 | + | if ch == '\n' { | |
| 217 | + | if !last_was_newline { | |
| 218 | + | result.push('\n'); | |
| 219 | + | last_was_newline = true; | |
| 220 | + | } | |
| 221 | + | } else { | |
| 222 | + | result.push(ch); | |
| 223 | + | last_was_newline = false; | |
| 224 | + | } | |
| 225 | + | } | |
| 226 | + | } | |
| 227 | + | ||
| 228 | + | result.trim().to_string() | |
| 229 | + | } | |
| 230 | + | ||
| 231 | + | /// Update progress on the import job. | |
| 232 | + | async fn update_progress(pool: &PgPool, job_id: ImportJobId, processed: i32, created: i32, skipped: i32) { | |
| 233 | + | if let Err(e) = db::imports::update_import_progress(pool, job_id, processed, created, skipped).await { | |
| 234 | + | tracing::warn!(error = %e, "failed to update import progress"); | |
| 235 | + | } | |
| 236 | + | } | |
| 237 | + | ||
| 238 | + | #[cfg(test)] | |
| 239 | + | mod tests { | |
| 240 | + | use super::*; | |
| 241 | + | ||
| 242 | + | #[test] | |
| 243 | + | fn strip_html_basic() { | |
| 244 | + | assert_eq!(strip_html_tags("<p>Hello</p>"), "Hello"); | |
| 245 | + | } | |
| 246 | + | ||
| 247 | + | #[test] | |
| 248 | + | fn strip_html_br_tags() { | |
| 249 | + | assert_eq!(strip_html_tags("line1<br>line2"), "line1\nline2"); | |
| 250 | + | assert_eq!(strip_html_tags("line1<br/>line2"), "line1\nline2"); | |
| 251 | + | } | |
| 252 | + | ||
| 253 | + | #[test] | |
| 254 | + | fn strip_html_nested() { | |
| 255 | + | assert_eq!( | |
| 256 | + | strip_html_tags("<div><p>Hello <strong>world</strong></p></div>"), | |
| 257 | + | "Hello world" | |
| 258 | + | ); | |
| 259 | + | } | |
| 260 | + | ||
| 261 | + | #[test] | |
| 262 | + | fn strip_html_collapses_newlines() { | |
| 263 | + | assert_eq!( | |
| 264 | + | strip_html_tags("<p>One</p><p>Two</p><p>Three</p>"), | |
| 265 | + | "One\nTwo\nThree" | |
| 266 | + | ); | |
| 267 | + | } | |
| 268 | + | ||
| 269 | + | #[test] | |
| 270 | + | fn strip_html_empty() { | |
| 271 | + | assert_eq!(strip_html_tags(""), ""); | |
| 272 | + | assert_eq!(strip_html_tags("<br><br><br>"), ""); | |
| 273 | + | } | |
| 274 | + | } |
| @@ -11,6 +11,7 @@ pub mod error; | |||
| 11 | 11 | pub mod fingerprint; | |
| 12 | 12 | pub mod git; | |
| 13 | 13 | pub mod helpers; | |
| 14 | + | pub mod import; | |
| 14 | 15 | pub mod monitor; | |
| 15 | 16 | pub mod mt_client; | |
| 16 | 17 | pub mod payments; |
| @@ -0,0 +1,158 @@ | |||
| 1 | + | //! Import API endpoints: start import, check status, list jobs. | |
| 2 | + | ||
| 3 | + | use axum::{ | |
| 4 | + | extract::{Path, State}, | |
| 5 | + | response::IntoResponse, | |
| 6 | + | Json, | |
| 7 | + | }; | |
| 8 | + | use base64::Engine; | |
| 9 | + | use serde::Deserialize; | |
| 10 | + | use serde_json::json; | |
| 11 | + | ||
| 12 | + | use crate::{ | |
| 13 | + | auth::AuthUser, | |
| 14 | + | db::{self, ImportJobId, ProjectId}, | |
| 15 | + | error::{AppError, Result}, | |
| 16 | + | import::{self, ColumnMapping, ImportSource}, | |
| 17 | + | AppState, | |
| 18 | + | }; | |
| 19 | + | ||
| 20 | + | /// Maximum CSV size: 10 MB (base64-encoded). | |
| 21 | + | const MAX_CSV_SIZE: usize = 10 * 1024 * 1024; | |
| 22 | + | ||
| 23 | + | /// Request body for starting an import. | |
| 24 | + | #[derive(Debug, Deserialize)] | |
| 25 | + | pub(super) struct StartImportRequest { | |
| 26 | + | pub project_id: ProjectId, | |
| 27 | + | pub source: ImportSource, | |
| 28 | + | pub csv_data: String, | |
| 29 | + | pub column_mapping: ColumnMapping, | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | /// Start a new import job from CSV data. | |
| 33 | + | /// | |
| 34 | + | /// Creates the job in `pending` status, then spawns a background task | |
| 35 | + | /// to parse the CSV and run the import pipeline. | |
| 36 | + | #[tracing::instrument(skip_all, name = "imports::start_import")] | |
| 37 | + | pub(super) async fn start_import( | |
| 38 | + | State(state): State<AppState>, | |
| 39 | + | AuthUser(user): AuthUser, | |
| 40 | + | Json(req): Json<StartImportRequest>, | |
| 41 | + | ) -> Result<impl IntoResponse> { | |
| 42 | + | user.check_not_suspended()?; | |
| 43 | + | ||
| 44 | + | // Validate project ownership | |
| 45 | + | super::verify_project_ownership(&state, req.project_id, user.id).await?; | |
| 46 | + | ||
| 47 | + | // Validate source (only generic_csv for Phase A) | |
| 48 | + | if req.source != ImportSource::GenericCsv { | |
| 49 | + | return Err(AppError::Validation(format!( | |
| 50 | + | "Import source '{}' is not yet supported. Only 'generic_csv' is available.", | |
| 51 | + | req.source, | |
| 52 | + | ))); | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | // Decode base64 CSV data | |
| 56 | + | let csv_bytes = base64::engine::general_purpose::STANDARD | |
| 57 | + | .decode(&req.csv_data) | |
| 58 | + | .map_err(|_| AppError::Validation("Invalid base64-encoded CSV data".into()))?; | |
| 59 | + | ||
| 60 | + | if csv_bytes.len() > MAX_CSV_SIZE { | |
| 61 | + | return Err(AppError::Validation(format!( | |
| 62 | + | "CSV data exceeds maximum size of {} MB", | |
| 63 | + | MAX_CSV_SIZE / (1024 * 1024), | |
| 64 | + | ))); | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | // Parse CSV into payload | |
| 68 | + | let payload = import::csv_converter::parse_csv(&csv_bytes, &req.column_mapping)?; | |
| 69 | + | let total_rows = payload.total_rows() as i32; | |
| 70 | + | ||
| 71 | + | // Create import job | |
| 72 | + | let job = db::imports::create_import_job( | |
| 73 | + | &state.db, | |
| 74 | + | user.id, | |
| 75 | + | req.project_id, | |
| 76 | + | req.source, | |
| 77 | + | total_rows, | |
| 78 | + | ) | |
| 79 | + | .await?; | |
| 80 | + | ||
| 81 | + | let job_id = job.id; | |
| 82 | + | let pool = state.db.clone(); | |
| 83 | + | let project_id = req.project_id; | |
| 84 | + | let user_id = user.id; | |
| 85 | + | ||
| 86 | + | // Spawn background task for the import pipeline | |
| 87 | + | tokio::spawn(async move { | |
| 88 | + | if let Err(e) = import::pipeline::run_import( | |
| 89 | + | &pool, | |
| 90 | + | job_id, | |
| 91 | + | project_id, | |
| 92 | + | user_id, | |
| 93 | + | payload, | |
| 94 | + | ) | |
| 95 | + | .await | |
| 96 | + | { | |
| 97 | + | tracing::error!(error = %e, job_id = %job_id, "import pipeline failed"); | |
| 98 | + | let _ = db::imports::fail_import_job(&pool, job_id, &e.to_string()).await; | |
| 99 | + | } | |
| 100 | + | }); | |
| 101 | + | ||
| 102 | + | Ok(Json(json!({ "job_id": job_id }))) | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | /// Get the status and progress of an import job. | |
| 106 | + | #[tracing::instrument(skip_all, name = "imports::get_import_status")] | |
| 107 | + | pub(super) async fn get_import_status( | |
| 108 | + | State(state): State<AppState>, | |
| 109 | + | AuthUser(user): AuthUser, | |
| 110 | + | Path(id): Path<String>, | |
| 111 | + | ) -> Result<impl IntoResponse> { | |
| 112 | + | let job_id: ImportJobId = id.parse().map_err(|_| AppError::NotFound)?; | |
| 113 | + | ||
| 114 | + | let job = db::imports::get_import_job(&state.db, job_id, user.id) | |
| 115 | + | .await? | |
| 116 | + | .ok_or(AppError::NotFound)?; | |
| 117 | + | ||
| 118 | + | Ok(Json(json!({ | |
| 119 | + | "id": job.id, | |
| 120 | + | "source": job.source.to_string(), | |
| 121 | + | "status": job.status.to_string(), | |
| 122 | + | "total_rows": job.total_rows, | |
| 123 | + | "processed_rows": job.processed_rows, | |
| 124 | + | "created_rows": job.created_rows, | |
| 125 | + | "skipped_rows": job.skipped_rows, | |
| 126 | + | "error_log": job.error_log, | |
| 127 | + | "created_at": job.created_at, | |
| 128 | + | "completed_at": job.completed_at, | |
| 129 | + | }))) | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | /// List all import jobs for the authenticated user. | |
| 133 | + | #[tracing::instrument(skip_all, name = "imports::list_imports")] | |
| 134 | + | pub(super) async fn list_imports( | |
| 135 | + | State(state): State<AppState>, | |
| 136 | + | AuthUser(user): AuthUser, | |
| 137 | + | ) -> Result<impl IntoResponse> { | |
| 138 | + | let jobs = db::imports::list_import_jobs(&state.db, user.id).await?; | |
| 139 | + | ||
| 140 | + | let data: Vec<serde_json::Value> = jobs | |
| 141 | + | .into_iter() | |
| 142 | + | .map(|j| { | |
| 143 | + | json!({ | |
| 144 | + | "id": j.id, | |
| 145 | + | "source": j.source.to_string(), | |
| 146 | + | "status": j.status.to_string(), | |
| 147 | + | "total_rows": j.total_rows, | |
| 148 | + | "processed_rows": j.processed_rows, | |
| 149 | + | "created_rows": j.created_rows, | |
| 150 | + | "skipped_rows": j.skipped_rows, | |
| 151 | + | "created_at": j.created_at, | |
| 152 | + | "completed_at": j.completed_at, | |
| 153 | + | }) | |
| 154 | + | }) | |
| 155 | + | .collect(); | |
| 156 | + | ||
| 157 | + | Ok(Json(json!({ "data": data }))) | |
| 158 | + | } |
| @@ -37,6 +37,7 @@ mod reports; | |||
| 37 | 37 | mod collections; | |
| 38 | 38 | mod validate; | |
| 39 | 39 | mod domains; | |
| 40 | + | mod imports; | |
| 40 | 41 | mod internal; | |
| 41 | 42 | ||
| 42 | 43 | use axum::{ | |
| @@ -323,6 +324,8 @@ pub fn api_routes() -> Router<AppState> { | |||
| 323 | 324 | .route("/api/domains/{id}", delete(domains::remove_domain)) | |
| 324 | 325 | // Invite codes | |
| 325 | 326 | .route("/api/invites/create", post(users::create_invite)) | |
| 327 | + | // Import system | |
| 328 | + | .route("/api/users/me/import", post(imports::start_import)) | |
| 326 | 329 | // Email signup (public, landing page notify-me) | |
| 327 | 330 | .route("/api/email-signup", post(email_signup)) | |
| 328 | 331 | .route_layer(GovernorLayer { | |
| @@ -383,6 +386,9 @@ pub fn api_routes() -> Router<AppState> { | |||
| 383 | 386 | .route("/api/domains", get(domains::get_domain)) | |
| 384 | 387 | .route("/api/domains/caddy-ask", get(domains::caddy_ask)) | |
| 385 | 388 | .route("/api/restart-status", get(internal::restart_status)) | |
| 389 | + | // Import system (read) | |
| 390 | + | .route("/api/users/me/import/{id}", get(imports::get_import_status)) | |
| 391 | + | .route("/api/users/me/imports", get(imports::list_imports)) | |
| 386 | 392 | // License text (public) | |
| 387 | 393 | .route("/api/items/{id}/license.txt", get(license_keys::license_text)); | |
| 388 | 394 |
| @@ -96,6 +96,44 @@ pub(super) async fn export_portal( | |||
| 96 | 96 | }) | |
| 97 | 97 | } | |
| 98 | 98 | ||
| 99 | + | /// Render the data import portal page. | |
| 100 | + | #[tracing::instrument(skip_all, name = "dashboard_forms::import_portal")] | |
| 101 | + | pub(super) async fn import_portal( | |
| 102 | + | State(state): State<AppState>, | |
| 103 | + | session: Session, | |
| 104 | + | AuthUser(session_user): AuthUser, | |
| 105 | + | ) -> Result<impl IntoResponse> { | |
| 106 | + | let csrf_token = get_csrf_token(&session).await; | |
| 107 | + | ||
| 108 | + | let db_projects = db::projects::get_projects_by_user(&state.db, session_user.id).await?; | |
| 109 | + | let projects: Vec<crate::templates::ImportProjectOption> = db_projects | |
| 110 | + | .into_iter() | |
| 111 | + | .map(|p| crate::templates::ImportProjectOption { | |
| 112 | + | id: p.id.to_string(), | |
| 113 | + | title: p.title, | |
| 114 | + | }) | |
| 115 | + | .collect(); | |
| 116 | + | ||
| 117 | + | let db_jobs = db::imports::list_import_jobs(&state.db, session_user.id).await?; | |
| 118 | + | let jobs: Vec<crate::templates::ImportJobRow> = db_jobs | |
| 119 | + | .into_iter() | |
| 120 | + | .map(|j| crate::templates::ImportJobRow { | |
| 121 | + | source: j.source.to_string(), | |
| 122 | + | status: j.status.to_string(), | |
| 123 | + | total_rows: j.total_rows, | |
| 124 | + | created_rows: j.created_rows, | |
| 125 | + | created_at: j.created_at, | |
| 126 | + | }) | |
| 127 | + | .collect(); | |
| 128 | + | ||
| 129 | + | Ok(ImportPortalTemplate { | |
| 130 | + | csrf_token, | |
| 131 | + | session_user: Some(session_user), | |
| 132 | + | projects, | |
| 133 | + | jobs, | |
| 134 | + | }) | |
| 135 | + | } | |
| 136 | + | ||
| 99 | 137 | /// Render the account deletion confirmation page. | |
| 100 | 138 | #[tracing::instrument(skip_all, name = "dashboard_forms::delete_account_page")] | |
| 101 | 139 | pub(super) async fn delete_account_page( |
| @@ -79,6 +79,7 @@ pub fn dashboard_routes() -> Router<AppState> { | |||
| 79 | 79 | .route("/dashboard/item/{id}/edit-row", get(forms::item_edit_row)) | |
| 80 | 80 | .route("/dashboard/project/{slug}/blog/new", get(forms::blog_editor)) | |
| 81 | 81 | .route("/dashboard/export", get(forms::export_portal)) | |
| 82 | + | .route("/dashboard/import", get(forms::import_portal)) | |
| 82 | 83 | .route("/dashboard/delete-account", get(forms::delete_account_page)) | |
| 83 | 84 | .route("/dashboard/onboarding/dismiss", post(main::dismiss_onboarding)) | |
| 84 | 85 | .route("/dashboard/onboarding/restore", post(main::restore_onboarding)) |
| @@ -167,6 +167,31 @@ pub struct ExportPortalTemplate { | |||
| 167 | 167 | pub content_size: String, | |
| 168 | 168 | } | |
| 169 | 169 | ||
| 170 | + | /// Data import portal for migrating from other platforms. | |
| 171 | + | #[derive(Template)] | |
| 172 | + | #[template(path = "dashboards/dashboard-import.html")] | |
| 173 | + | pub struct ImportPortalTemplate { | |
| 174 | + | pub csrf_token: CsrfTokenOption, | |
| 175 | + | pub session_user: Option<SessionUser>, | |
| 176 | + | pub projects: Vec<ImportProjectOption>, | |
| 177 | + | pub jobs: Vec<ImportJobRow>, | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | /// Minimal project info for the import page project selector. | |
| 181 | + | pub struct ImportProjectOption { | |
| 182 | + | pub id: String, | |
| 183 | + | pub title: String, | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | /// A row in the import history table. | |
| 187 | + | pub struct ImportJobRow { | |
| 188 | + | pub source: String, | |
| 189 | + | pub status: String, | |
| 190 | + | pub total_rows: i32, | |
| 191 | + | pub created_rows: i32, | |
| 192 | + | pub created_at: chrono::DateTime<chrono::Utc>, | |
| 193 | + | } | |
| 194 | + | ||
| 170 | 195 | #[derive(Template)] | |
| 171 | 196 | #[template(path = "dashboards/dashboard-delete-account.html")] | |
| 172 | 197 | pub struct DeleteAccountTemplate { |