From 95c3bc21fa2f516f38722b1fd3612d635876a9ed Mon Sep 17 00:00:00 2001 From: null Date: Mon, 6 Jul 2026 21:02:22 -0500 Subject: [PATCH] feat(dates): Firestore-backed date catalog + sponsored ideas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 · " 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 --- .../data/remote/FirestoreCollections.kt | 1 + .../remote/FirestoreDateIdeaDataSource.kt | 50 +++ .../repository/DateMatchRepositoryImpl.kt | 11 +- .../java/app/closer/domain/model/DateIdea.kt | 9 +- .../app/closer/ui/dates/DateMatchScreen.kt | 50 ++- .../repository/DateMatchRepositoryImplTest.kt | 29 +- functions/scripts/date_ideas_seed.json | 347 ++++++++++++++++++ functions/scripts/seedDateIdeas.ts | 94 +++++ 8 files changed, 576 insertions(+), 15 deletions(-) create mode 100644 app/src/main/java/app/closer/data/remote/FirestoreDateIdeaDataSource.kt create mode 100644 functions/scripts/date_ideas_seed.json create mode 100644 functions/scripts/seedDateIdeas.ts diff --git a/app/src/main/java/app/closer/data/remote/FirestoreCollections.kt b/app/src/main/java/app/closer/data/remote/FirestoreCollections.kt index b35f7d51..cd291037 100644 --- a/app/src/main/java/app/closer/data/remote/FirestoreCollections.kt +++ b/app/src/main/java/app/closer/data/remote/FirestoreCollections.kt @@ -12,6 +12,7 @@ object FirestoreCollections { const val USERS = "users" const val COUPLES = "couples" const val INVITES = "invites" + const val DATE_IDEAS = "date_ideas" // ── Subcollections under users/{uid} ────────────────────────────────────── object Users { diff --git a/app/src/main/java/app/closer/data/remote/FirestoreDateIdeaDataSource.kt b/app/src/main/java/app/closer/data/remote/FirestoreDateIdeaDataSource.kt new file mode 100644 index 00000000..6223b005 --- /dev/null +++ b/app/src/main/java/app/closer/data/remote/FirestoreDateIdeaDataSource.kt @@ -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? = 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" + } +} diff --git a/app/src/main/java/app/closer/data/repository/DateMatchRepositoryImpl.kt b/app/src/main/java/app/closer/data/repository/DateMatchRepositoryImpl.kt index 5da6f170..e460ebab 100644 --- a/app/src/main/java/app/closer/data/repository/DateMatchRepositoryImpl.kt +++ b/app/src/main/java/app/closer/data/repository/DateMatchRepositoryImpl.kt @@ -1,5 +1,6 @@ package app.closer.data.repository +import app.closer.data.remote.FirestoreDateIdeaDataSource import app.closer.data.remote.FirestoreDateMatchDataSource import app.closer.data.remote.FirestoreDateSwipeDataSource import app.closer.domain.model.DateIdea @@ -30,10 +31,16 @@ import javax.inject.Singleton @Singleton class DateMatchRepositoryImpl @Inject constructor( private val swipeDataSource: FirestoreDateSwipeDataSource, - private val matchDataSource: FirestoreDateMatchDataSource + private val matchDataSource: FirestoreDateMatchDataSource, + private val dateIdeaDataSource: FirestoreDateIdeaDataSource ) : DateMatchRepository { - override suspend fun getDateIdeas(): List = 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 { + val remote = dateIdeaDataSource.getDateIdeas() + return if (remote.isNullOrEmpty()) DateIdeaSeed.all else remote + } override suspend fun recordSwipe( coupleId: String, diff --git a/app/src/main/java/app/closer/domain/model/DateIdea.kt b/app/src/main/java/app/closer/domain/model/DateIdea.kt index 9e250c8e..c937d8a1 100644 --- a/app/src/main/java/app/closer/domain/model/DateIdea.kt +++ b/app/src/main/java/app/closer/domain/model/DateIdea.kt @@ -11,6 +11,10 @@ package app.closer.domain.model * @property estimatedCost Cost level: free, low, medium, high. * @property isPremium True when the idea requires an active premium entitlement. * @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( val id: String = "", @@ -20,7 +24,10 @@ data class DateIdea( val estimatedDuration: String = "", val estimatedCost: DateCostLevel = DateCostLevel.FREE, 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 { diff --git a/app/src/main/java/app/closer/ui/dates/DateMatchScreen.kt b/app/src/main/java/app/closer/ui/dates/DateMatchScreen.kt index 5f501cde..c18ff9f9 100644 --- a/app/src/main/java/app/closer/ui/dates/DateMatchScreen.kt +++ b/app/src/main/java/app/closer/ui/dates/DateMatchScreen.kt @@ -8,6 +8,7 @@ import app.closer.ui.theme.closerSoftSurfaceColor import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.spring import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.detectHorizontalDragGestures import androidx.compose.foundation.layout.Arrangement 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.vector.ImageVector 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.style.TextOverflow import androidx.compose.ui.unit.IntOffset @@ -356,7 +358,9 @@ private fun DateCard( verticalAlignment = Alignment.CenterVertically ) { CategoryPill(idea.category.displayDateCategory()) - if (idea.isPremium) { + if (idea.sponsored) { + SponsoredPill(idea.sponsorName) + } else if (idea.isPremium) { PremiumBadge() } } @@ -378,18 +382,48 @@ private fun DateCard( ) } - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.Bottom - ) { - InfoChip(label = idea.estimatedDuration) - InfoChip(label = idea.estimatedCost.displayLabel()) + 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( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Bottom + ) { + InfoChip(label = idea.estimatedDuration) + InfoChip(label = idea.estimatedCost.displayLabel()) + } } } } } +@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 private fun PremiumBadge() { Surface( diff --git a/app/src/test/java/app/closer/data/repository/DateMatchRepositoryImplTest.kt b/app/src/test/java/app/closer/data/repository/DateMatchRepositoryImplTest.kt index 823d8d72..10de1981 100644 --- a/app/src/test/java/app/closer/data/repository/DateMatchRepositoryImplTest.kt +++ b/app/src/test/java/app/closer/data/repository/DateMatchRepositoryImplTest.kt @@ -1,5 +1,6 @@ package app.closer.data.repository +import app.closer.data.remote.FirestoreDateIdeaDataSource import app.closer.data.remote.FirestoreDateMatchDataSource import app.closer.data.remote.FirestoreDateSwipeDataSource import app.closer.domain.model.DateSwipe @@ -17,7 +18,8 @@ class DateMatchRepositoryImplTest { private val swipeDataSource: FirestoreDateSwipeDataSource = 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 fun `recordSwipe records via data source and always returns null match`() = runTest { @@ -54,9 +56,28 @@ class DateMatchRepositoryImplTest { } @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() - assertTrue("Seed catalog must not be empty", ideas.isNotEmpty()) - assertTrue("All ideas must have non-blank ids", ideas.all { it.id.isNotBlank() }) + assertTrue("must fall back to the non-empty seed", ideas.isNotEmpty()) + 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) } } diff --git a/functions/scripts/date_ideas_seed.json b/functions/scripts/date_ideas_seed.json new file mode 100644 index 00000000..d24aa8c9 --- /dev/null +++ b/functions/scripts/date_ideas_seed.json @@ -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" + } +] \ No newline at end of file diff --git a/functions/scripts/seedDateIdeas.ts b/functions/scripts/seedDateIdeas.ts new file mode 100644 index 00000000..39b97e41 --- /dev/null +++ b/functions/scripts/seedDateIdeas.ts @@ -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() + 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 { + const doc: Record = { + 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) +})