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:
parent
6ecc98eec2
commit
95c3bc21fa
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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<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(
|
||||
coupleId: String,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
|
|
@ -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)
|
||||
})
|
||||
Loading…
Reference in New Issue