#!/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"), ], } 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), "location": g.get("location", ""), "description": g.get("description", ""), "status": g.get("status", "open"), } for g in gigs ], ) print("done") if __name__ == "__main__": main()