Skip to main content

max / makenotwork

Import system (Phase 13D-A): CSV import pipeline, mailing list subscribers Migration 055 (import_jobs table + email-only mailing list subscribers). Common intermediate format (ImportPayload), generic CSV converter with flexible date/currency/BOM handling, import pipeline for tiers, items, tags, and subscribers with chunked progress. 3 API endpoints, dashboard UI with CSV preview, column auto-detection, and progress polling. 28 unit tests + 8 integration tests. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-04-10 16:31 UTC
Commit: 4c7b0a19452f5edb16dacbd4d919cecb36e89e42
Parent: 83079a3
25 files changed, +2415 insertions, -7 deletions
M Cargo.lock +23 -1
@@ -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",
M Cargo.toml +3
@@ -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
M docs/todo.md +31 -5
@@ -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 + .email
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 + &cents_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 + }
M src/lib.rs +1
@@ -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 {