feat(dates): Firestore-backed date catalog + sponsored ideas

- DateIdea gains sponsored/sponsorName/externalUrl (native, clearly-labeled ads
  — no tracking SDK).
- FirestoreDateIdeaDataSource reads the server-curated date_ideas collection
  (defensive: malformed docs skipped, https-only externalUrl); DateMatchRepo
  serves remote first, falling back to the shipped static seed when empty/failed
  so the feature always has content. Fallback verified live (Date Match renders
  the seed) + unit-tested (remote-present / empty / failure).
- Date card shows a "Sponsored · <name>" pill and a "Learn more" link (opened via
  the system browser, https-only, no click IDs).
- Local admin seeder (functions/scripts/seedDateIdeas.ts + date_ideas_seed.json,
  generated from the Kotlin seed + 1 sponsored example) — idempotent by id, has a
  --dry-run; NOT a deployed endpoint. Verified: 38 docs upserted to the emulator,
  1 sponsored, tsc builds.

Owner: run the seeder against prod (GOOGLE_APPLICATION_CREDENTIALS) to populate
date_ideas; add real sponsor content.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
null 2026-07-06 21:02:22 -05:00
parent 6ecc98eec2
commit 95c3bc21fa
8 changed files with 576 additions and 15 deletions

View File

@ -12,6 +12,7 @@ object FirestoreCollections {
const val USERS = "users" const val USERS = "users"
const val COUPLES = "couples" const val COUPLES = "couples"
const val INVITES = "invites" const val INVITES = "invites"
const val DATE_IDEAS = "date_ideas"
// ── Subcollections under users/{uid} ────────────────────────────────────── // ── Subcollections under users/{uid} ──────────────────────────────────────
object Users { object Users {

View File

@ -0,0 +1,50 @@
package app.closer.data.remote
import android.util.Log
import app.closer.domain.model.DateCostLevel
import app.closer.domain.model.DateIdea
import com.google.firebase.firestore.DocumentSnapshot
import com.google.firebase.firestore.FirebaseFirestore
import kotlinx.coroutines.tasks.await
import javax.inject.Inject
import javax.inject.Singleton
/**
* Reads the curated date-idea catalog from the top-level `date_ideas` collection (server-curated,
* read-only for clients per the rules). Server-backed so sponsored/seasonal ideas can be updated
* without an app release. A malformed remote doc is skipped, never crashing the list; a fetch
* failure returns null so the caller can fall back to the shipped static seed.
*/
@Singleton
class FirestoreDateIdeaDataSource @Inject constructor(
private val db: FirebaseFirestore,
) {
/** Returns the remote catalog, or null on failure (caller falls back to the static seed). */
suspend fun getDateIdeas(): List<DateIdea>? = runCatching {
db.collection(FirestoreCollections.DATE_IDEAS).get().await()
.documents
.mapNotNull { runCatching { it.toDateIdea() }.getOrNull() }
}.onFailure { Log.w(TAG, "date_ideas fetch failed; using static seed", it) }.getOrNull()
private fun DocumentSnapshot.toDateIdea(): DateIdea? {
val title = getString("title")?.takeIf { it.isNotBlank() } ?: return null
val url = getString("externalUrl")?.takeIf { it.startsWith("https://") } // https-only
return DateIdea(
id = id,
title = title,
description = getString("description").orEmpty(),
category = getString("category").orEmpty(),
estimatedDuration = getString("estimatedDuration").orEmpty(),
estimatedCost = DateCostLevel.fromFirestoreValue(getString("estimatedCost").orEmpty()),
isPremium = getBoolean("isPremium") ?: false,
createdAt = getLong("createdAt") ?: 0L,
sponsored = getBoolean("sponsored") ?: false,
sponsorName = getString("sponsorName")?.takeIf { it.isNotBlank() },
externalUrl = url,
)
}
private companion object {
const val TAG = "FirestoreDateIdeaDS"
}
}

View File

@ -1,5 +1,6 @@
package app.closer.data.repository package app.closer.data.repository
import app.closer.data.remote.FirestoreDateIdeaDataSource
import app.closer.data.remote.FirestoreDateMatchDataSource import app.closer.data.remote.FirestoreDateMatchDataSource
import app.closer.data.remote.FirestoreDateSwipeDataSource import app.closer.data.remote.FirestoreDateSwipeDataSource
import app.closer.domain.model.DateIdea import app.closer.domain.model.DateIdea
@ -30,10 +31,16 @@ import javax.inject.Singleton
@Singleton @Singleton
class DateMatchRepositoryImpl @Inject constructor( class DateMatchRepositoryImpl @Inject constructor(
private val swipeDataSource: FirestoreDateSwipeDataSource, private val swipeDataSource: FirestoreDateSwipeDataSource,
private val matchDataSource: FirestoreDateMatchDataSource private val matchDataSource: FirestoreDateMatchDataSource,
private val dateIdeaDataSource: FirestoreDateIdeaDataSource
) : DateMatchRepository { ) : DateMatchRepository {
override suspend fun getDateIdeas(): List<DateIdea> = DateIdeaSeed.all // Server-curated catalog (lets sponsored/seasonal ideas update without a release). The shipped
// static seed is the offline/empty/error fallback, so the feature always has content.
override suspend fun getDateIdeas(): List<DateIdea> {
val remote = dateIdeaDataSource.getDateIdeas()
return if (remote.isNullOrEmpty()) DateIdeaSeed.all else remote
}
override suspend fun recordSwipe( override suspend fun recordSwipe(
coupleId: String, coupleId: String,

View File

@ -11,6 +11,10 @@ package app.closer.domain.model
* @property estimatedCost Cost level: free, low, medium, high. * @property estimatedCost Cost level: free, low, medium, high.
* @property isPremium True when the idea requires an active premium entitlement. * @property isPremium True when the idea requires an active premium entitlement.
* @property createdAt Server timestamp of when the idea was added. * @property createdAt Server timestamp of when the idea was added.
* @property sponsored True when this is a clearly-labeled sponsored/partner idea (native ad not a
* tracking SDK). Server-curated; the UI shows a "Sponsored" chip.
* @property sponsorName Display name of the sponsor, shown on the chip.
* @property externalUrl Optional https link opened via Chrome Custom Tabs (no click IDs / tracking).
*/ */
data class DateIdea( data class DateIdea(
val id: String = "", val id: String = "",
@ -20,7 +24,10 @@ data class DateIdea(
val estimatedDuration: String = "", val estimatedDuration: String = "",
val estimatedCost: DateCostLevel = DateCostLevel.FREE, val estimatedCost: DateCostLevel = DateCostLevel.FREE,
val isPremium: Boolean = false, val isPremium: Boolean = false,
val createdAt: Long = 0L val createdAt: Long = 0L,
val sponsored: Boolean = false,
val sponsorName: String? = null,
val externalUrl: String? = null
) )
enum class DateCostLevel { enum class DateCostLevel {

View File

@ -8,6 +8,7 @@ import app.closer.ui.theme.closerSoftSurfaceColor
import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring import androidx.compose.animation.core.spring
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectHorizontalDragGestures import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@ -51,6 +52,7 @@ import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
@ -356,7 +358,9 @@ private fun DateCard(
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
CategoryPill(idea.category.displayDateCategory()) CategoryPill(idea.category.displayDateCategory())
if (idea.isPremium) { if (idea.sponsored) {
SponsoredPill(idea.sponsorName)
} else if (idea.isPremium) {
PremiumBadge() PremiumBadge()
} }
} }
@ -378,6 +382,17 @@ private fun DateCard(
) )
} }
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
idea.externalUrl?.takeIf { it.startsWith("https://") }?.let { url ->
val uriHandler = LocalUriHandler.current
Text(
text = "Learn more ↗",
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.clickable { runCatching { uriHandler.openUri(url) } }
)
}
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
@ -388,6 +403,25 @@ private fun DateCard(
} }
} }
} }
}
}
@Composable
private fun SponsoredPill(sponsorName: String?) {
Surface(
shape = RoundedCornerShape(999.dp),
color = closerSoftSurfaceColor()
) {
Text(
text = sponsorName?.takeIf { it.isNotBlank() }?.let { "Sponsored · $it" } ?: "Sponsored",
modifier = Modifier.padding(horizontal = 12.dp, vertical = 7.dp),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
} }
@Composable @Composable

View File

@ -1,5 +1,6 @@
package app.closer.data.repository package app.closer.data.repository
import app.closer.data.remote.FirestoreDateIdeaDataSource
import app.closer.data.remote.FirestoreDateMatchDataSource import app.closer.data.remote.FirestoreDateMatchDataSource
import app.closer.data.remote.FirestoreDateSwipeDataSource import app.closer.data.remote.FirestoreDateSwipeDataSource
import app.closer.domain.model.DateSwipe import app.closer.domain.model.DateSwipe
@ -17,7 +18,8 @@ class DateMatchRepositoryImplTest {
private val swipeDataSource: FirestoreDateSwipeDataSource = mockk(relaxed = true) private val swipeDataSource: FirestoreDateSwipeDataSource = mockk(relaxed = true)
private val matchDataSource: FirestoreDateMatchDataSource = mockk(relaxed = true) private val matchDataSource: FirestoreDateMatchDataSource = mockk(relaxed = true)
private val repository = DateMatchRepositoryImpl(swipeDataSource, matchDataSource) private val dateIdeaDataSource: FirestoreDateIdeaDataSource = mockk(relaxed = true)
private val repository = DateMatchRepositoryImpl(swipeDataSource, matchDataSource, dateIdeaDataSource)
@Test @Test
fun `recordSwipe records via data source and always returns null match`() = runTest { fun `recordSwipe records via data source and always returns null match`() = runTest {
@ -54,9 +56,28 @@ class DateMatchRepositoryImplTest {
} }
@Test @Test
fun `getDateIdeas returns non-empty seed catalog with valid ids`() = runTest { fun `getDateIdeas falls back to the static seed when the remote catalog is empty`() = runTest {
coEvery { dateIdeaDataSource.getDateIdeas() } returns emptyList()
val ideas = repository.getDateIdeas() val ideas = repository.getDateIdeas()
assertTrue("Seed catalog must not be empty", ideas.isNotEmpty()) assertTrue("must fall back to the non-empty seed", ideas.isNotEmpty())
assertTrue("All ideas must have non-blank ids", ideas.all { it.id.isNotBlank() }) assertTrue(ideas.all { it.id.isNotBlank() })
}
@Test
fun `getDateIdeas falls back to the static seed when the remote fetch fails`() = runTest {
coEvery { dateIdeaDataSource.getDateIdeas() } returns null
val ideas = repository.getDateIdeas()
assertTrue(ideas.isNotEmpty())
}
@Test
fun `getDateIdeas serves the remote catalog when present`() = runTest {
val remote = listOf(
app.closer.domain.model.DateIdea(id = "remote_1", title = "Remote idea", sponsored = true, sponsorName = "Acme")
)
coEvery { dateIdeaDataSource.getDateIdeas() } returns remote
val ideas = repository.getDateIdeas()
assertTrue(ideas.size == 1 && ideas[0].id == "remote_1")
assertTrue("sponsored fields flow through", ideas[0].sponsored)
} }
} }

View File

@ -0,0 +1,347 @@
[
{
"id": "adventure_sunrise_hike",
"title": "Sunrise hike + thermos coffee",
"description": "Pick a nearby overlook, pack warm drinks, and watch the sky change together.",
"category": "adventure",
"estimatedDuration": "2\u20133 hours",
"estimatedCost": "free",
"isPremium": false
},
{
"id": "adventure_kayak",
"title": "Kayak or canoe rental",
"description": "Spend a quiet morning paddling a local lake or river.",
"category": "adventure",
"estimatedDuration": "Half day",
"estimatedCost": "medium",
"isPremium": false
},
{
"id": "adventure_rock_climbing",
"title": "Indoor rock climbing",
"description": "Try a beginner climbing gym session and cheer each other up the wall.",
"category": "adventure",
"estimatedDuration": "2 hours",
"estimatedCost": "medium",
"isPremium": false
},
{
"id": "adventure_overnight_camping",
"title": "Overnight camping getaway",
"description": "Pack a tent and disappear for one night somewhere with dark skies.",
"category": "adventure",
"estimatedDuration": "1\u20132 days",
"estimatedCost": "medium",
"isPremium": true
},
{
"id": "adventure_zipline",
"title": "Zipline canopy tour",
"description": "Fly through the trees on a guided zip-line course.",
"category": "adventure",
"estimatedDuration": "Half day",
"estimatedCost": "high",
"isPremium": true
},
{
"id": "travel_day_town",
"title": "Day trip to a nearby town",
"description": "Drive somewhere neither of you has explored and wander without an itinerary.",
"category": "travel",
"estimatedDuration": "Full day",
"estimatedCost": "low",
"isPremium": false
},
{
"id": "travel_scenic_drive",
"title": "Scenic drive with a playlist",
"description": "Build a shared playlist, pick a pretty route, and stop for snacks.",
"category": "travel",
"estimatedDuration": "3\u20134 hours",
"estimatedCost": "low",
"isPremium": false
},
{
"id": "travel_weekend_escape",
"title": "Surprise weekend escape",
"description": "One partner plans the destination; the other packs a bag blind.",
"category": "travel",
"estimatedDuration": "2 days",
"estimatedCost": "high",
"isPremium": true
},
{
"id": "travel_roadside_attractions",
"title": "Roadside attractions tour",
"description": "Find the weirdest small attractions within a two-hour radius and visit three.",
"category": "travel",
"estimatedDuration": "Full day",
"estimatedCost": "low",
"isPremium": true
},
{
"id": "food_farmers_market",
"title": "Farmers market breakfast",
"description": "Buy fresh pastries and fruit, then eat somewhere people-watching is easy.",
"category": "food",
"estimatedDuration": "2 hours",
"estimatedCost": "low",
"isPremium": false
},
{
"id": "food_cook_together",
"title": "Cook a new cuisine together",
"description": "Pick a recipe neither of you has made, shop together, and make a mess.",
"category": "food",
"estimatedDuration": "3 hours",
"estimatedCost": "low",
"isPremium": false
},
{
"id": "food_food_truck_crawl",
"title": "Food truck crawl",
"description": "Share three different dishes from three different trucks.",
"category": "food",
"estimatedDuration": "2 hours",
"estimatedCost": "medium",
"isPremium": false
},
{
"id": "food_tasting_menu",
"title": "Tasting menu dinner",
"description": "Book a special restaurant and make an evening of every course.",
"category": "food",
"estimatedDuration": "3 hours",
"estimatedCost": "high",
"isPremium": true
},
{
"id": "food_winery_tour",
"title": "Winery or brewery tour",
"description": "Taste flights, learn something, and bring home a bottle to share later.",
"category": "food",
"estimatedDuration": "Half day",
"estimatedCost": "high",
"isPremium": true
},
{
"id": "food_pizza_night",
"title": "Homemade pizza night",
"description": "Make dough from scratch and compete for the most ridiculous topping combo.",
"category": "food",
"estimatedDuration": "2\u20133 hours",
"estimatedCost": "low",
"isPremium": false
},
{
"id": "learning_museum",
"title": "Museum or gallery visit",
"description": "Walk slowly through an exhibit and share the one piece that moved you most.",
"category": "learning",
"estimatedDuration": "2\u20133 hours",
"estimatedCost": "low",
"isPremium": false
},
{
"id": "learning_dance_class",
"title": "Beginner dance class",
"description": "Try salsa, swing, or ballroom \u2014 no experience required.",
"category": "learning",
"estimatedDuration": "1.5 hours",
"estimatedCost": "medium",
"isPremium": false
},
{
"id": "learning_workshop",
"title": "Creative workshop",
"description": "Pottery, painting, woodworking \u2014 make something tangible together.",
"category": "learning",
"estimatedDuration": "2\u20133 hours",
"estimatedCost": "medium",
"isPremium": false
},
{
"id": "learning_language",
"title": "Learn a language lesson together",
"description": "Pick a travel destination and learn ten phrases as a team.",
"category": "learning",
"estimatedDuration": "1 hour",
"estimatedCost": "free",
"isPremium": true
},
{
"id": "learning_lecture",
"title": "Attend a lecture or talk",
"description": "Find a local speaker on a topic you both know nothing about.",
"category": "learning",
"estimatedDuration": "2 hours",
"estimatedCost": "low",
"isPremium": true
},
{
"id": "romance_sunset_picnic",
"title": "Sunset picnic",
"description": "Pack a blanket, simple snacks, and watch the sun go down somewhere quiet.",
"category": "romance",
"estimatedDuration": "2 hours",
"estimatedCost": "low",
"isPremium": false
},
{
"id": "romance_stargazing",
"title": "Stargazing with blankets",
"description": "Drive away from city lights, lie back, and name constellations.",
"category": "romance",
"estimatedDuration": "2 hours",
"estimatedCost": "free",
"isPremium": false
},
{
"id": "romance_couples_massage",
"title": "Couples massage",
"description": "Book side-by-side treatments and unwind together.",
"category": "romance",
"estimatedDuration": "2 hours",
"estimatedCost": "high",
"isPremium": true
},
{
"id": "romance_sunset_sail",
"title": "Sunset sail or boat cruise",
"description": "Catch the golden hour from the water with a drink in hand.",
"category": "romance",
"estimatedDuration": "2\u20133 hours",
"estimatedCost": "high",
"isPremium": true
},
{
"id": "romance_home_spa",
"title": "At-home spa night",
"description": "Face masks, candles, soft music, and zero pressure to do anything else.",
"category": "romance",
"estimatedDuration": "2 hours",
"estimatedCost": "low",
"isPremium": false
},
{
"id": "intimacy_question_deck",
"title": "Deeper question card night",
"description": "Use a deck of intimacy questions and answer them honestly over wine or tea.",
"category": "intimacy",
"estimatedDuration": "1.5 hours",
"estimatedCost": "free",
"isPremium": false
},
{
"id": "intimacy_slow_cook",
"title": "Slow-cook dinner with no phones",
"description": "Make a meal that takes real time and spend every minute talking.",
"category": "intimacy",
"estimatedDuration": "3\u20134 hours",
"estimatedCost": "low",
"isPremium": false
},
{
"id": "intimacy_letters",
"title": "Write each other letters",
"description": "Handwrite what you appreciate about each other, seal them, then read aloud.",
"category": "intimacy",
"estimatedDuration": "1 hour",
"estimatedCost": "free",
"isPremium": true
},
{
"id": "intimacy_trust_exercise",
"title": "Guided trust exercise",
"description": "Follow a short couples communication exercise with eye contact and listening.",
"category": "intimacy",
"estimatedDuration": "1 hour",
"estimatedCost": "free",
"isPremium": true
},
{
"id": "intimacy_walk_memory_lane",
"title": "Walk down memory lane",
"description": "Visit the place you first met or had your first real date and retell the story.",
"category": "intimacy",
"estimatedDuration": "1.5 hours",
"estimatedCost": "free",
"isPremium": false
},
{
"id": "seasonal_ice_skating",
"title": "Ice skating",
"description": "Hold hands, wobble, and warm up with hot cocoa afterward.",
"category": "seasonal",
"estimatedDuration": "2 hours",
"estimatedCost": "medium",
"isPremium": false
},
{
"id": "seasonal_fall_foliage",
"title": "Fall foliage drive",
"description": "Take the prettiest route, stop for cider, and take a silly photo together.",
"category": "seasonal",
"estimatedDuration": "Half day",
"estimatedCost": "low",
"isPremium": false
},
{
"id": "seasonal_beach_day",
"title": "Off-season beach day",
"description": "Go to the beach when it is nearly empty and walk the shoreline.",
"category": "seasonal",
"estimatedDuration": "Half day",
"estimatedCost": "low",
"isPremium": false
},
{
"id": "seasonal_holiday_lights",
"title": "Holiday lights walk",
"description": "Find the most over-decorated neighborhood and stroll after dark.",
"category": "seasonal",
"estimatedDuration": "1 hour",
"estimatedCost": "free",
"isPremium": true
},
{
"id": "seasonal_pumpkin_patch",
"title": "Pumpkin patch or apple orchard",
"description": "Pick seasonal produce and turn it into something tasty at home.",
"category": "seasonal",
"estimatedDuration": "Half day",
"estimatedCost": "low",
"isPremium": false
},
{
"id": "romance_rooftop_drink",
"title": "Rooftop drink at golden hour",
"description": "Find the highest bar with outdoor space and watch the city light up.",
"category": "romance",
"estimatedDuration": "1.5 hours",
"estimatedCost": "medium",
"isPremium": true
},
{
"id": "adventure_bike_ride",
"title": "Neighborhood bike ride",
"description": "Borrow or rent bikes and explore streets you usually drive past.",
"category": "adventure",
"estimatedDuration": "1.5 hours",
"estimatedCost": "free",
"isPremium": false
},
{
"id": "sponsored_example_cozy_bookshop",
"title": "Browse a local bookshop caf\u00e9",
"description": "Pick a book for each other, then swap over coffee.",
"category": "learning",
"estimatedDuration": "1\u20132 hours",
"estimatedCost": "low",
"isPremium": false,
"sponsored": true,
"sponsorName": "Sample Partner",
"externalUrl": "https://example.com/cozy-bookshop"
}
]

View File

@ -0,0 +1,94 @@
/**
* Seeds the top-level `date_ideas` collection from date_ideas_seed.json.
*
* A LOCAL admin script NOT a deployed callable (a deployed admin-write endpoint is avoidable
* attack surface). Run with admin credentials; idempotent by document id, so re-running updates in
* place rather than duplicating. Sponsored/seasonal ideas are edited here and pushed without an app
* release; the Android app falls back to its shipped static seed when the collection is empty.
*
* Usage:
* export GOOGLE_APPLICATION_CREDENTIALS=~/.config/closer/closer-admin-sa.json
* npx ts-node functions/scripts/seedDateIdeas.ts # writes
* npx ts-node functions/scripts/seedDateIdeas.ts --dry-run # prints what would change
*/
import * as admin from 'firebase-admin'
import * as fs from 'fs'
import * as path from 'path'
interface SeedIdea {
id: string
title: string
description?: string
category?: string
estimatedDuration?: string
estimatedCost?: string
isPremium?: boolean
sponsored?: boolean
sponsorName?: string
externalUrl?: string
}
const DRY_RUN = process.argv.includes('--dry-run')
function loadSeed(): SeedIdea[] {
const file = path.join(__dirname, 'date_ideas_seed.json')
const ideas = JSON.parse(fs.readFileSync(file, 'utf8')) as SeedIdea[]
const ids = new Set<string>()
for (const idea of ideas) {
if (!idea.id || !idea.title) throw new Error(`seed entry missing id/title: ${JSON.stringify(idea)}`)
if (ids.has(idea.id)) throw new Error(`duplicate seed id: ${idea.id}`)
ids.add(idea.id)
if (idea.externalUrl && !idea.externalUrl.startsWith('https://')) {
throw new Error(`externalUrl must be https: ${idea.id}`)
}
}
return ideas
}
function toDoc(idea: SeedIdea): Record<string, unknown> {
const doc: Record<string, unknown> = {
title: idea.title,
description: idea.description ?? '',
category: idea.category ?? '',
estimatedDuration: idea.estimatedDuration ?? '',
estimatedCost: idea.estimatedCost ?? 'free',
isPremium: idea.isPremium ?? false,
sponsored: idea.sponsored ?? false,
createdAt: Date.now(),
}
if (idea.sponsorName) doc.sponsorName = idea.sponsorName
if (idea.externalUrl) doc.externalUrl = idea.externalUrl
return doc
}
async function main() {
const ideas = loadSeed()
console.log(`Loaded ${ideas.length} date ideas (${ideas.filter((i) => i.sponsored).length} sponsored).`)
if (DRY_RUN) {
for (const idea of ideas) console.log(` would upsert date_ideas/${idea.id}`)
console.log('Dry run — nothing written.')
return
}
if (!admin.apps.length) admin.initializeApp()
const db = admin.firestore()
const col = db.collection('date_ideas')
// Batch upsert (merge) by id — idempotent.
let batch = db.batch()
let n = 0
for (const idea of ideas) {
batch.set(col.doc(idea.id), toDoc(idea), { merge: true })
if (++n % 400 === 0) {
await batch.commit()
batch = db.batch()
}
}
await batch.commit()
console.log(`Seeded ${ideas.length} ideas into date_ideas.`)
}
main().catch((err) => {
console.error(err)
process.exit(1)
})