Email BYOK is done but a few platform key leaks remain. The test page should be deleted entirely. Inbox replies need workspace scoping. After cleanup, the admin panel gets gated behind existing accounts, the marketing webapp handles signup, Gemsutopia becomes a fully operational ecommerce store manageable from the admin panel, and eventually the CMS gets overhauled so any user can manage every piece of content on their connected site.
Scope: Small (~1-2 hours)
- Delete:
apps/admin/app/(dashboard)/developers/test/(page.tsx + actions.ts) - Remove: Any sidebar nav link to the test page
- Remove: Any imports/references to
sendTestShippingEmail,createTestInboxEmail,getTestOrdersfrom other files - It was a dev-only test page for toasts, error states, emails — no longer needed
- File:
apps/admin/app/(dashboard)/notifications/messages/actions.ts - The reply function sends from
support@quickdash.netusing the platform key - Fix: Use
getWorkspaceResend(workspace.id)+getWorkspaceEmailConfig(workspace.id) - Replies from Gemsutopia's inbox should go out from
noreply@gemsutopia.ca(or whatever their workspace email is), NOT fromsupport@quickdash.net - Also check
apps/admin/app/(dashboard)/messages/actions.tsfor the same issue
After 1A and 1B, the platform RESEND_API_KEY should ONLY be used for:
- Auth transactional emails (
lib/auth.ts): password reset, email verification →noreply@quickdash.net - Contact form (
apps/web/api/contact/route.ts): marketing site contact form confirmation - Platform email helpers (
lib/send-email.ts,lib/inngest/email-handlers.ts): only when noworkspaceIdis provided (platform-level operations)
The Quickdash workspace under admin@quickdash.net uses the platform key — that's correct since it's your business. Every other workspace uses their own BYOK keys.
Scope: Medium (~4-6 hours) | Prerequisite for Phase 3
- New file:
apps/admin/middleware.ts - Check for Better Auth session cookie on all routes
- Allow-list:
/login,/forgot-password,/reset-password,/api/auth/*,/api/webhooks/*,/api/storefront/*,/api/v1/*,/api/inngest,/api/pusher/auth,/pricing - Redirect unauthenticated users to
/login
- Remove or redirect:
apps/admin/app/signup/→ redirect tohttps://quickdash.net/signup - Update:
apps/admin/components/login-form.tsx— "Sign up" link points tohttps://quickdash.net/signup - Keep
/loginin admin (existing users go here)
- File:
apps/admin/lib/auth.ts - Set Better Auth cookie domain to
.quickdash.netso sessions fromquickdash.netcarry toapp.quickdash.net - Add
https://quickdash.nettotrustedOrigins - User signs up on web → session carries → admin recognizes them
Scope: Large (~2-3 days) | Depends on Phase 2
- New:
apps/web/lib/auth.ts— same DB, secret, social providers, cookie domain.quickdash.net - New:
apps/web/app/api/auth/[...all]/route.ts
- New:
apps/web/app/signup/— signup form - New:
apps/web/app/onboarding/— streamlined flow:- Profile (name, username, avatar)
- Tier selection (hobby free / lite / essentials / pro → Polar checkout)
- Workspace creation (name, type, domain)
- Connect site (GitHub or manual) + optionally select a template (future)
- On completion: set
onboardingCompletedAt, redirect tohttps://app.quickdash.net
- Rewrite:
apps/web/app/page.tsx— landing page - Move: Pricing from
apps/admin/app/pricing/→apps/web/app/pricing/ - Keep: Contact page
apps/web→quickdash.neton Vercelapps/admin→app.quickdash.net(unchanged)
Scope: Medium-Large (~2-3 days) | Can run in parallel with Phase 3
The goal: Gemsutopia is fully manageable from the Quickdash admin panel. Reese drops his payment keys into the dashboard and starts selling. We build the website so it just works when keys are present.
- Build a unified checkout flow that dynamically shows payment options based on which keys are configured in the workspace
- Stripe checkout: Already exists at
/api/storefront/payments/stripe/checkout— verify it works with workspace BYOK keys - Polar checkout: New endpoint
apps/admin/app/api/storefront/payments/polar/checkout/route.ts— uses workspace's Polar credentials - Reown/crypto checkout: New endpoint
apps/admin/app/api/storefront/payments/reown/checkout/route.ts— uses workspace's WalletConnect project ID + chain config - PayPal: Defer (Reese isn't using it yet), but the BYOK key storage already supports it
- Update Gemsutopia's
CheckoutFlow.tsxto check which providers have keys and show those options - Add
payments.createPolarSession()andpayments.createReownSession()to the SDK
- Operational work in admin UI — create Gemsutopia's product catalog
- Categories, products with variants, images, prices in CAD
- This is data entry, not code
- Before the CMS overhaul, use existing collections for all site content
- Create/populate: hero, navigation, footer, social-links, about, FAQ, testimonials, stats, partners, etc.
- Gemsutopia's
StorefrontClientalready consumes these
- Wire up all admin-managed content to the frontend
- Ensure every piece of text, image, and link comes from the API (no hardcoded content)
- Test full flow: browse → add to cart → checkout → pay → order appears in admin
- Deploy gemsutopia.ca
- Add missing resources to
packages/sdk:collections,blog,pages,siteContent - Eventually Gemsutopia should use the official SDK package instead of its local
StorefrontClientcopy
Scope: Large (~1 week) | Saved for later
Users can't manage all the content on their connected site from Quickdash. Collections work great for lists of things (FAQ, team members, gallery), but there's no clean way to manage one-off page content (hero heading, footer text, nav links) or link content across types (a blog post's author → a team member entry).
- Not a website builder — no drag-and-drop page layout, no visual editor
- Not Content Zones (previous plan) — that couples content to pages, which is the frontend's job
- Quickdash is a headless CMS — it stores structured content and serves it via API. The frontend decides what goes where.
We already have 80% of a headless CMS. The system is:
- Content Collections (done) — user-defined structured content types with JSONB schemas
- Site Content (done) — key-value store for one-off content, grouped by prefix
- Blog Posts + Site Pages (done) — specialized content types
What's missing is relational power, better API access, and richer field types.
Scope: Medium
Add a reference field type to the collection schema system. A reference field points to entries in another collection.
Schema change:
// Add to FieldType union:
type FieldType = "text" | "textarea" | ... | "reference"
// Reference fields have extra config:
type CollectionField = {
key: string
label: string
type: FieldType
required?: boolean
// ... existing fields ...
referenceCollection?: string // slug of the target collection (for type="reference")
referenceMultiple?: boolean // allow multiple references (default false)
}Data storage: Reference fields store the target entry ID (or array of IDs) in the JSONB data.
Admin UI:
- Reference field renders as a searchable dropdown of entries from the target collection
- Shows the entry's titleField as the display label
- Multi-reference renders as a tag input
API response: Reference fields are returned as IDs by default. Add ?expand=author,category query param to inline the referenced entry data.
Files to modify:
packages/db/src/schema/content-collections.ts— add type to FieldType unionapps/admin/app/(dashboard)/content/collections/[slug]/field-renderer.tsx— reference picker UIapps/admin/app/(dashboard)/content/collections/[slug]/schema-editor.tsx— reference field config (pick target collection)apps/admin/app/api/storefront/collections/[slug]/route.ts— expand query param supportapps/admin/app/(dashboard)/content/collections/actions.ts— entry lookup for reference picker
Scope: Small
One API call to fetch multiple collections at once — so a frontend can get a page's worth of content in a single request instead of N requests.
New endpoint: GET /api/storefront/content/bundle
- Query:
?collections=hero-slides,testimonials,featured-products - Optional:
?collections=hero-slides:3,testimonials:5(with per-collection limits) - Response:
{
"hero-slides": { "collection": {...}, "entries": [...] },
"testimonials": { "collection": {...}, "entries": [...] },
"featured-products": { "collection": {...}, "entries": [...] }
}File: apps/admin/app/api/storefront/content/bundle/route.ts
Scope: Small
Expose the key-value site_content table to storefronts via API, grouped by prefix.
New endpoint: GET /api/storefront/site/content
- Returns all site content entries grouped by prefix (text before the colon)
- Response:
{
"hero": { "heading": "...", "subheading": "...", "image": "https://..." },
"footer": { "text": "...", "copyright": "..." },
"nav": { "items": "[{\"label\":\"Home\",\"href\":\"/\"}]" }
}Optional: GET /api/storefront/site/content/:prefix — just one group
File: apps/admin/app/api/storefront/site/content/route.ts
Scope: Medium (defer after 5A-5C)
Add a richtext field type that stores structured content as JSON (TipTap/ProseMirror format) or HTML. This replaces the current plain textarea for content that needs formatting, embedded images, links.
Not urgent — textarea + markdown works fine for v1. Revisit when template developers need it.
// Bundle fetch — one call for a page's content
client.content.bundle(["hero-slides", "testimonials", "featured-products"])
// Site content — global key-value content
client.site.content() // all groups
client.site.content("hero") // just hero group
// Collections — already exists, enhanced with expand
client.collections.list("team", { expand: ["department"] })5A (Reference fields) — adds relational power
↓
5B (Bundle endpoint) — one-call page content
↓
5C (Site content API) — expose globals to storefronts
↓
5D (Rich text) — defer, not blocking
5A-5C can ship incrementally. Each is independently useful.
Scope: Large | Depends on Phase 3 + 5
- Framework-agnostic templates (Next.js, Svelte, Angular, vanilla HTML/JS) pre-wired with SDK
- Templates ship with predefined collections + site content keys that auto-populate on install
- Marketplace in
apps/web - Payment via Polar
- One template per framework to prove cross-framework compatibility
Phase 1 (Cleanup) — DO FIRST, standalone
↓
Phase 2 (Auth Gate) — next, standalone
↓
Phase 3 (Web App) ←──── depends on Phase 2 (shared auth)
↓
Phase 4 (Gemsutopia) — parallel with Phase 3
↓
Phase 5 (CMS Overhaul) — after Phase 4 (learnings inform design)
↓
Phase 6 (Template Store) — after Phase 3 + 5
- Phase 1: No test page in sidebar. Reply from Gemsutopia inbox → email sent from
noreply@gemsutopia.ca, notsupport@quickdash.net. Grep forRESEND_API_KEYconfirms only auth/platform usage. - Phase 2:
app.quickdash.net/signup→ redirects toquickdash.net/signup. Any admin route without session →/login. - Phase 3: Sign up at
quickdash.net/signup→ onboard → land onapp.quickdash.netwith session intact. - Phase 4: Browse gemsutopia.ca → add to cart → checkout with Stripe/Polar/Reown → order appears in Gemsutopia workspace. All content comes from admin, no hardcoded text.
- Phase 5: Create "Team" collection with reference field to "Departments" → API returns expanded data. Bundle endpoint returns multiple collections in one call. Site content API returns grouped key-value content.
Gemsutopia's site is broken because the site content editor stored images as base64 data URLs directly in the site_content.value column. The hero images alone are 21 MB of base64. The API returns a 30 MB JSON response on every page load, causing Maximum call stack size exceeded in Next.js server rendering.
Quickdash already uses Vercel Blob for products, blog, media library, and messages — but the site content editor and collection field renderer have no image upload UI, so base64 got pasted into text fields.
Goals:
- Fix Gemsutopia immediately (migrate base64 → blob URLs)
- Prevent this from ever happening again (proper upload UI everywhere)
- Workspace-scope all uploads (
media/{workspaceId}/...) - Add storage limits per tier to prevent storage abuse at scale
File: apps/admin/app/api/upload/route.ts
- Change blob path from
products/{timestamp}-{file}tomedia/{workspaceId}/{timestamp}-{file} - Require workspace context (use
requireWorkspace()or get from session) - Add storage tracking: after upload, record file size in
media_itemstable or increment workspace storage counter - Add storage limit check BEFORE upload: query current usage, compare against tier limit, reject if over
New file: apps/admin/app/api/admin/migrate-media/route.ts (one-time migration endpoint, protected)
Safe migration process:
- Query all
site_contentrows wherevalue LIKE 'data:image%'(across ALL workspaces) - For each row:
a. Extract MIME type and base64 data from the data URL
b. Convert to Buffer
c. Upload to Vercel Blob at
media/{workspaceId}/site-content/{key}-{timestamp}.{ext}d. Verify the upload succeeded (blob URL is returned, non-empty) e. Update the DB row:SET value = blob_url WHERE id = row.id AND value = original_base64_value(optimistic lock — only updates if value hasn't changed) f. Log:[Migration] {key}: {size} → {blob_url} - Also scan
content_entries.dataJSONB for any base64 values and migrate those - Return summary:
{ migrated: N, failed: N, skipped: N, details: [...] }
Safety guarantees:
- Optimistic locking on update (WHERE value = old_value)
- Upload verified before any DB write
- Idempotent: re-running skips already-migrated rows (value won't start with
data:image) - Dry-run mode: add
?dry=truequery param to preview without writing - All operations logged
Every place that accepts an image (products, auctions, categories, site content, collections, blog) should have two options:
- Upload new — drag-and-drop upload (existing
MediaUploadercomponent) - Choose from library — browse existing
media_itemsfor this workspace
File: apps/admin/components/media-uploader.tsx
- Add a "Media Library" button next to the upload area
- Opens a modal/sheet showing the workspace's
media_items(grid of thumbnails) - User clicks an image → it gets added to the selected items
- Reuses the existing media library UI from
content/media/media-library.tsx
File: apps/admin/app/(dashboard)/content/site-content/site-content-editor.tsx
- Detect image fields:
item.type === "image"OR key ends with:imageor:images - For image fields: render
MediaUploader(upload new OR pick from library) - For
hero:images(JSON array): multi-image uploader - Show image preview for existing URL values
File: apps/admin/app/(dashboard)/content/collections/[slug]/field-renderer.tsx
case "image": renderMediaUploaderwithmaxItems={1}instead of plain URL input- Upload new or pick from library
- Categories should have drag-and-drop image upload (not just URL input)
- Same MediaUploader component with library picker
Tracks: total bytes uploaded per workspace
- Add
storageUsedBytes: biginttoworkspacestable (default 0) - Increment on upload, decrement on delete
| Tier | Storage Limit |
|---|---|
| hobby | 500 MB |
| lite | 2 GB |
| essentials | 10 GB |
| pro | 50 GB |
| beta | 50 GB |
| File Type | Max Size |
|---|---|
| Images (jpg, png, webp, gif, svg) | 10 MB |
| Videos (mp4, webm, mov) | 100 MB |
| Audio (mp3, wav, flac, ogg, aac, m4a) | 50 MB |
Before uploading, check: workspace.storageUsedBytes + file.size <= tierLimit
If over: return { error: "Storage limit reached. Upgrade your plan." } with 413 status.
Show storage usage bar on the billing/settings page: "2.1 GB / 10 GB used"
The media_items table already tracks: url, filename, mimeType, size, workspaceId. Ensure all uploads (including site content images) create a media_items record so we have a central registry of all files.
Step 1 (Scope upload route) — workspace paths + storage tracking
↓
Step 2 (Migrate base64) — fix Gemsutopia NOW
↓
Step 3 (Media library picker) — upload + pick from library everywhere
↓
Step 4 (Storage limits) — tier enforcement
↓
Step 5 (Media registry) — central file tracking
Steps 1-2 are urgent (fixes the broken site). Steps 3-5 are preventive.
- Migration safe: Run with
?dry=truefirst, verify output shows correct files and sizes - Migration works: Run migration, verify
site_contentvalues are now URLs (not base64), verify blob URLs load in browser - Gemsutopia loads: After migration, API response < 100KB (was 30MB)
- Site renders: Gemsutopia homepage shows hero images, about sections, quality section, CTA
- Upload works: Upload an image via site content editor → goes to blob, value saved as URL
- Library picker: Click "Media Library" on product form → see existing images → select one
- Storage limit: Upload past tier limit → get 413 error
- No data loss: Every base64 image has a corresponding blob URL. Original data preserved in blob storage permanently.