nomadweb/backend/scripts/init_pb_collections.py
eric 9b3f210bd2 Add city detail depth, enriched routes, and meetup auth flows.
Port NomadCNA city_details/content/routes seeding into PocketBase and surface full destination pages with related meetups and discussions.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-30 11:54:10 -05:00

456 lines
13 KiB
Python

#!/usr/bin/env python3
"""Bootstrap PocketBase collections for nomadweb (run on server after deploy)."""
from __future__ import annotations
import sys
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from app.data import community_data # noqa: E402
from app.services.pb_client import PocketBaseError, pb # noqa: E402
def text(name: str, *, required: bool = False, max: int = 0) -> dict[str, Any]:
return {"name": name, "type": "text", "required": required, "max": max}
def number(name: str, *, only_int: bool = False) -> dict[str, Any]:
return {"name": name, "type": "number", "onlyInt": only_int}
def boolean(name: str) -> dict[str, Any]:
return {"name": name, "type": "bool"}
def json_field(name: str) -> dict[str, Any]:
return {"name": name, "type": "json"}
def date(name: str) -> dict[str, Any]:
return {"name": name, "type": "date"}
COLLECTIONS: dict[str, list[dict[str, Any]]] = {
"destinations": [
text("slug", required=True, max=80),
text("name", required=True, max=120),
text("country", max=80),
text("emoji", max=16),
text("region", max=80),
number("cost", only_int=True),
number("speed", only_int=True),
number("temperature", only_int=True),
number("rating"),
number("hue", only_int=True),
text("tag", max=80),
text("description", max=500),
text("nomads_count", max=40),
json_field("highlights"),
number("map_x"),
number("map_y"),
],
"discussions": [
text("title", required=True, max=255),
text("author", max=120),
text("authorUserId", max=80),
text("authorEmoji", max=16),
text("category", max=80),
json_field("tags"),
text("excerpt", max=1000),
number("replies", only_int=True),
number("views", only_int=True),
number("likes", only_int=True),
boolean("pinned"),
text("legacyId", max=120),
],
"discussion_replies": [
text("discussionId", required=True, max=80),
text("userId", max=80),
text("author", max=120),
text("authorEmoji", max=16),
text("body", required=True, max=2000),
],
"discussion_likes": [
text("discussionId", required=True, max=80),
text("userId", required=True, max=80),
],
"meetups": [
text("legacyId", max=120),
text("title", required=True, max=255),
text("city", max=120),
text("destinationSlug", max=80),
text("emoji", max=16),
date("date"),
text("time", max=20),
text("venue", max=255),
text("description", max=1000),
number("rsvpCount", only_int=True),
number("maxAttendees", only_int=True),
text("organizer", max=120),
text("organizerId", max=80),
text("mode", max=40),
text("accessLevel", max=40),
text("mirotalkRoom", max=160),
text("loungeChannel", max=120),
json_field("tags"),
boolean("isUpcoming"),
text("status", max=40),
],
"meetup_rsvps": [
text("meetupId", required=True, max=80),
text("userId", required=True, max=80),
text("status", max=40),
],
"gigs": [
text("legacyId", max=120),
text("title", required=True, max=255),
text("category", max=120),
number("budget", only_int=True),
text("location", max=120),
text("description", max=1000),
text("status", max=40),
text("posterId", max=80),
text("posterName", max=120),
],
"gig_applications": [
text("gigId", required=True, max=80),
text("userId", max=80),
text("applicant", max=255),
text("message", max=1000),
text("status", max=40),
],
"profiles": [
text("userId", max=80),
text("email", max=255),
text("name", max=120),
number("age", only_int=True),
text("location", max=120),
json_field("tags"),
text("bio", max=1000),
text("photo", max=500),
text("gender", max=40),
json_field("lookingFor"),
text("citySlug", max=80),
],
"swipes": [
text("userId", max=80),
text("profileId", max=80),
text("action", max=40),
text("intent", max=40),
text("swipeDate", max=20),
],
"match_connections": [
text("pairKey", max=160),
text("userAId", max=80),
text("userBId", max=80),
text("intent", max=40),
text("conversationId", max=80),
date("matchedAt"),
],
"conversations": [
text("pairKey", max=160),
text("userAId", max=80),
text("userBId", max=80),
text("intent", max=40),
text("lastMessagePreview", max=200),
date("lastMessageAt"),
json_field("readState"),
],
"messages": [
text("conversationId", required=True, max=80),
text("senderId", required=True, max=80),
text("body", required=True, max=2000),
],
"notifications": [
text("title", required=True, max=255),
text("body", max=2000),
text("category", max=80),
text("targetUserId", max=80),
text("actionUrl", max=500),
text("status", max=40),
boolean("read"),
boolean("archived"),
boolean("pinned"),
],
"nomad_accounts": [
text("email", required=True, max=255),
text("passwordHash", max=128),
text("name", max=120),
text("avatar", max=16),
text("legacyUserId", max=80),
json_field("favorites"),
json_field("plan"),
],
"nomad_sessions": [
text("token", required=True, max=128),
text("userId", required=True, max=80),
number("expiresAt", only_int=True),
],
"media_assets": [
text("userId", max=80),
text("url", required=True, max=500),
text("filename", max=255),
text("contentType", max=120),
number("size", only_int=True),
text("objectKey", max=500),
],
"subscriptions": [
text("email", required=True, max=255),
],
"visas": [text("country", max=80), text("type", max=80), number("difficulty", only_int=True)],
"faqs": [text("question", max=500), text("answer", max=2000), number("order", only_int=True)],
"blog_posts": [
text("slug", max=120),
text("title", max=255),
text("excerpt", max=500),
date("published_at"),
],
"tools": [text("name", max=120), text("url", max=500), text("description", max=500)],
"memberships": [
text("userId", required=True, max=80),
text("plan", max=40),
number("expiresAt", only_int=True),
],
"orders": [
text("orderId", required=True, max=120),
text("userId", max=120),
text("payType", max=60),
number("amount", only_int=True),
text("status", max=40),
],
"feedback": [
text("type", max=40),
text("email", max=255),
text("title", max=255),
text("content", max=2000),
text("status", max=40),
],
"service_leads": [
text("serviceSlug", required=True, max=120),
text("userId", max=80),
text("email", max=255),
text("note", max=1000),
text("status", max=40),
],
"content_submissions": [
text("title", required=True, max=255),
text("type", max=40),
text("mediaUrl", max=500),
text("submittedBy", max=80),
text("authorName", max=120),
text("description", max=2000),
text("reviewStatus", max=40),
],
"volunteer_applications": [
text("citySlug", required=True, max=120),
text("userId", max=80),
text("applicantName", max=140),
text("email", max=255),
text("motivation", max=2000),
text("status", max=40),
],
"reports": [
text("reporterId", max=80),
text("targetType", max=80),
text("targetId", max=120),
text("reason", max=2000),
text("status", max=40),
],
"notification_preferences": [
text("userId", required=True, max=80),
json_field("channels"),
],
"city_details": [
text("citySlug", required=True, max=120),
json_field("guide"),
json_field("pros"),
json_field("reviews"),
json_field("cost"),
json_field("people"),
json_field("chat"),
json_field("photos"),
json_field("weather"),
json_field("trends"),
json_field("demographics"),
json_field("relatedContent"),
],
"routes": [
text("slug", required=True, max=120),
text("title", required=True, max=255),
text("titleEn", max=255),
json_field("citySlugs"),
number("durationDays", only_int=True),
number("budget", only_int=True),
text("description", max=1000),
text("descriptionEn", max=1000),
json_field("stops"),
text("status", max=40),
text("emoji", max=16),
],
"content_items": [
text("slug", required=True, max=120),
text("type", required=True, max=40),
text("title", required=True, max=255),
text("titleEn", max=255),
text("subtitle", max=500),
text("description", max=2000),
text("coverImage", max=500),
text("mediaUrl", max=500),
text("targetUrl", max=500),
text("ctaLabel", max=120),
json_field("citySlugs"),
number("sortOrder", only_int=True),
text("status", max=40),
text("authorName", max=120),
],
"recommendation_logs": [
text("userId", max=80),
number("budget", only_int=True),
number("internet", only_int=True),
text("climate", max=80),
json_field("tags"),
json_field("resultSlugs"),
],
}
def collection_exists(name: str) -> bool:
try:
pb.request("GET", f"/api/collections/{name}", admin=True)
return True
except PocketBaseError:
return False
def ensure_collection(name: str, fields: list[dict[str, Any]]) -> None:
if collection_exists(name):
print(f"exists: {name}")
return
payload = {
"name": name,
"type": "base",
"listRule": "",
"viewRule": "",
"createRule": "",
"updateRule": "",
"deleteRule": "",
"fields": fields,
}
pb.request("POST", "/api/collections", admin=True, json=payload)
print(f"created: {name}")
def seed_if_empty(collection: str, records: list[dict[str, Any]]) -> None:
data = pb.list_records(collection, per_page=1)
if (data.get("totalItems") or 0) > 0:
print(f"seed skip: {collection}")
return
for record in records:
pb.create_record(collection, record)
print(f"seeded: {collection} ({len(records)})")
def meetup_payload(m: dict[str, Any]) -> dict[str, Any]:
date_val = m.get("date")
if date_val and "T" not in str(date_val):
date_val = f"{date_val} 00:00:00.000Z"
return {
"legacyId": m.get("id", ""),
"title": m.get("title", ""),
"city": m.get("city", ""),
"destinationSlug": m.get("destination_slug", ""),
"emoji": m.get("emoji", ""),
"date": date_val,
"time": m.get("time", ""),
"venue": m.get("venue", ""),
"description": m.get("description", ""),
"rsvpCount": m.get("rsvp_count", 0),
"maxAttendees": m.get("max_attendees", 30),
"organizer": m.get("organizer", ""),
"mode": m.get("mode", "offline"),
"accessLevel": m.get("access_level", "public"),
"mirotalkRoom": m.get("mirotalkRoom", ""),
"loungeChannel": m.get("loungeChannel", ""),
"tags": m.get("tags", []),
"isUpcoming": m.get("is_upcoming", True),
"status": "published",
}
def discussion_payload(d: dict[str, Any]) -> dict[str, Any]:
return {
"legacyId": d.get("id", ""),
"title": d.get("title", ""),
"author": d.get("author", ""),
"authorUserId": d.get("author_id", ""),
"authorEmoji": d.get("author_emoji", "🧑‍💻"),
"category": d.get("category", "社区"),
"tags": d.get("tags", []),
"excerpt": d.get("excerpt", ""),
"replies": d.get("reply_count", 0),
"views": d.get("view_count", 0),
"likes": d.get("like_count", 0),
"pinned": d.get("is_pinned", False),
}
def main() -> None:
if not pb.health_ok():
print("PocketBase not reachable")
sys.exit(1)
pb.admin_token()
for name, fields in COLLECTIONS.items():
ensure_collection(name, fields)
seed_if_empty("meetups", [meetup_payload(m) for m in community_data.MEETUPS])
seed_if_empty("discussions", [discussion_payload(d) for d in community_data.DISCUSSIONS])
gigs = getattr(community_data, "GIGS", [])
if gigs:
seed_if_empty(
"gigs",
[
{
"legacyId": g.get("id", ""),
"title": g.get("title", ""),
"category": g.get("category", ""),
"budget": g.get("budget", 0) if isinstance(g.get("budget"), int) else 0,
"location": g.get("location", ""),
"description": g.get("description", ""),
"status": g.get("status", "open"),
}
for g in gigs
],
)
from app.data.city_details_data import CITY_DETAILS, CONTENT_ITEMS, EXTRA_ROUTES
seed_if_empty("city_details", CITY_DETAILS)
seed_if_empty(
"routes",
[
{
"slug": r["slug"],
"title": r["title"],
"titleEn": r.get("titleEn", ""),
"citySlugs": r.get("citySlugs") or [],
"durationDays": r.get("durationDays", 0),
"budget": r.get("budget", 0),
"description": r.get("description", ""),
"descriptionEn": r.get("descriptionEn", ""),
"stops": r.get("stops") or [],
"status": r.get("status", "published"),
"emoji": r.get("emoji", "🗺️"),
}
for r in EXTRA_ROUTES
],
)
seed_if_empty("content_items", CONTENT_ITEMS)
print("done")
if __name__ == "__main__":
main()