// ABOUTME: Database setup script for creating PocketBase collections. // ABOUTME: Run with: POCKETBASE_ADMIN_EMAIL=... POCKETBASE_ADMIN_PASSWORD=... pnpm db:setup import PocketBase from "pocketbase"; /** * Collection field definition for PocketBase. * For relation fields, collectionId/maxSelect/cascadeDelete are top-level properties. */ interface CollectionField { name: string; type: string; required?: boolean; // Relation field properties (top-level, not in options) collectionId?: string; maxSelect?: number; cascadeDelete?: boolean; } /** * Collection definition for PocketBase. */ export interface CollectionDefinition { name: string; type: string; fields: CollectionField[]; } /** * Period logs collection schema - tracks menstrual cycle start dates. * Note: collectionId will be resolved at runtime to the actual users collection ID. */ export const PERIOD_LOGS_COLLECTION: CollectionDefinition = { name: "period_logs", type: "base", fields: [ { name: "user", type: "relation", required: true, collectionId: "users", // Will be resolved to actual ID at runtime maxSelect: 1, cascadeDelete: true, }, { name: "startDate", type: "date", required: true, }, { name: "predictedDate", type: "date", required: false, }, ], }; /** * Daily logs collection schema - daily training snapshots with biometrics. * Note: collectionId will be resolved at runtime to the actual users collection ID. */ export const DAILY_LOGS_COLLECTION: CollectionDefinition = { name: "dailyLogs", type: "base", fields: [ { name: "user", type: "relation", required: true, collectionId: "users", // Will be resolved to actual ID at runtime maxSelect: 1, cascadeDelete: true, }, { name: "date", type: "date", required: true, }, { name: "cycleDay", type: "number", required: true, }, { name: "phase", type: "text", required: true, }, { name: "bodyBatteryCurrent", type: "number", required: false, }, { name: "bodyBatteryYesterdayLow", type: "number", required: false, }, { name: "hrvStatus", type: "text", required: false, }, { name: "weekIntensityMinutes", type: "number", required: false, }, { name: "phaseLimit", type: "number", required: false, }, { name: "remainingMinutes", type: "number", required: false, }, { name: "trainingDecision", type: "text", required: true, }, { name: "decisionReason", type: "text", required: true, }, { name: "notificationSentAt", type: "date", required: false, }, ], }; /** * All collections that should exist in the database. */ const REQUIRED_COLLECTIONS = [PERIOD_LOGS_COLLECTION, DAILY_LOGS_COLLECTION]; /** * Custom fields to add to the users collection. * These are required for Garmin integration and app functionality. */ const USER_CUSTOM_FIELDS = [ { name: "garminConnected", type: "bool" }, { name: "garminOauth1Token", type: "text" }, { name: "garminOauth2Token", type: "text" }, { name: "garminTokenExpiresAt", type: "date" }, { name: "calendarToken", type: "text" }, { name: "lastPeriodDate", type: "date" }, { name: "cycleLength", type: "number" }, { name: "notificationTime", type: "text" }, { name: "timezone", type: "text" }, { name: "activeOverrides", type: "json" }, ]; /** * Adds custom fields to the users collection if they don't already exist. * This is idempotent - safe to run multiple times. */ export async function addUserFields(pb: PocketBase): Promise { const usersCollection = await pb.collections.getOne("users"); // Get existing field names const existingFieldNames = new Set( (usersCollection.fields || []).map((f: { name: string }) => f.name), ); // Filter to only new fields const newFields = USER_CUSTOM_FIELDS.filter( (f) => !existingFieldNames.has(f.name), ); if (newFields.length > 0) { // Combine existing fields with new ones const allFields = [...(usersCollection.fields || []), ...newFields]; await pb.collections.update(usersCollection.id, { fields: allFields, }); console.log( ` Added ${newFields.length} field(s) to users:`, newFields.map((f) => f.name), ); } else { console.log(" All user fields already exist."); } } /** * Gets the names of existing collections from PocketBase. */ export async function getExistingCollectionNames( pb: PocketBase, ): Promise { const collections = await pb.collections.getFullList(); return collections.map((c) => c.name); } /** * Returns collection definitions that don't exist in the database. */ export function getMissingCollections( existingNames: string[], ): CollectionDefinition[] { return REQUIRED_COLLECTIONS.filter( (collection) => !existingNames.includes(collection.name), ); } /** * Resolves collection name to actual collection ID. */ async function resolveCollectionId( pb: PocketBase, nameOrId: string, ): Promise { const collection = await pb.collections.getOne(nameOrId); return collection.id; } /** * Creates a collection in PocketBase. */ export async function createCollection( pb: PocketBase, collection: CollectionDefinition, ): Promise { // Resolve any collection names to actual IDs for relation fields const fields = await Promise.all( collection.fields.map(async (field) => { const baseField: Record = { name: field.name, type: field.type, required: field.required ?? false, }; // For relation fields, resolve collectionId and add relation-specific props if (field.type === "relation" && field.collectionId) { const resolvedId = await resolveCollectionId(pb, field.collectionId); baseField.collectionId = resolvedId; baseField.maxSelect = field.maxSelect ?? 1; baseField.cascadeDelete = field.cascadeDelete ?? false; } return baseField; }), ); await pb.collections.create({ name: collection.name, type: collection.type, fields, }); } /** * Main setup function - creates missing collections. */ async function main(): Promise { // Validate environment const pocketbaseUrl = process.env.NEXT_PUBLIC_POCKETBASE_URL || "http://localhost:8090"; const adminEmail = process.env.POCKETBASE_ADMIN_EMAIL; const adminPassword = process.env.POCKETBASE_ADMIN_PASSWORD; if (!adminEmail || !adminPassword) { console.error( "Error: POCKETBASE_ADMIN_EMAIL and POCKETBASE_ADMIN_PASSWORD are required", ); console.error("Usage:"); console.error( " POCKETBASE_ADMIN_EMAIL=admin@example.com POCKETBASE_ADMIN_PASSWORD=secret pnpm db:setup", ); process.exit(1); } console.log(`Connecting to PocketBase at ${pocketbaseUrl}...`); const pb = new PocketBase(pocketbaseUrl); pb.autoCancellation(false); // Authenticate as admin try { await pb .collection("_superusers") .authWithPassword(adminEmail, adminPassword); console.log("Authenticated as admin"); } catch (error) { console.error("Failed to authenticate as admin:", error); process.exit(1); } // Add custom fields to users collection console.log("Checking users collection fields..."); await addUserFields(pb); // Get existing collections const existingNames = await getExistingCollectionNames(pb); console.log( `Found ${existingNames.length} existing collections:`, existingNames, ); // Find and create missing collections const missing = getMissingCollections(existingNames); if (missing.length === 0) { console.log("All required collections already exist. Nothing to do."); return; } console.log( `Creating ${missing.length} missing collection(s):`, missing.map((c) => c.name), ); for (const collection of missing) { try { await createCollection(pb, collection); console.log(` Created: ${collection.name}`); } catch (error) { console.error(` Failed to create ${collection.name}:`, error); process.exit(1); } } console.log("Database setup complete!"); } // Run main function when executed directly const isMainModule = typeof require !== "undefined" && require.main === module; // For ES modules / tsx execution if (isMainModule || process.argv[1]?.includes("setup-db")) { main().catch((error) => { console.error("Setup failed:", error); process.exit(1); }); }