// 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. */ export interface CollectionField { name: string; type: string; required?: boolean; // Text field max length (PocketBase defaults to 5000 if not specified) max?: number; // 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. */ export const USER_CUSTOM_FIELDS: CollectionField[] = [ { name: "garminConnected", type: "bool" }, { name: "garminOauth1Token", type: "text", max: 20000 }, { name: "garminOauth2Token", type: "text", max: 20000 }, { name: "garminTokenExpiresAt", type: "date" }, { name: "garminRefreshTokenExpiresAt", 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" }, // Phase-specific intensity goals (weekly minutes) { name: "intensityGoalMenstrual", type: "number" }, { name: "intensityGoalFollicular", type: "number" }, { name: "intensityGoalOvulation", type: "number" }, { name: "intensityGoalEarlyLuteal", type: "number" }, { name: "intensityGoalLateLuteal", type: "number" }, ]; /** * Adds or updates custom fields on the users collection. * For new fields: adds them. For existing fields: updates max constraint if different. * This is idempotent - safe to run multiple times. */ export async function addUserFields(pb: PocketBase): Promise { const usersCollection = await pb.collections.getOne("users"); // Build a map of existing fields by name const existingFieldsMap = new Map>( (usersCollection.fields || []).map((f: Record) => [ f.name as string, f, ]), ); // Separate new fields from fields that need updating const newFields: CollectionField[] = []; const fieldsToUpdate: string[] = []; for (const definedField of USER_CUSTOM_FIELDS) { const existingField = existingFieldsMap.get(definedField.name); if (!existingField) { newFields.push(definedField); } else if ( definedField.max !== undefined && existingField.max !== definedField.max ) { fieldsToUpdate.push(definedField.name); existingField.max = definedField.max; } } const hasChanges = newFields.length > 0 || fieldsToUpdate.length > 0; if (hasChanges) { // Combine existing fields (with updates) and new fields const allFields = [...(usersCollection.fields || []), ...newFields]; await pb.collections.update(usersCollection.id, { fields: allFields, }); if (newFields.length > 0) { console.log( ` Added ${newFields.length} field(s) to users:`, newFields.map((f) => f.name), ); } if (fieldsToUpdate.length > 0) { console.log( ` Updated max constraint for ${fieldsToUpdate.length} field(s):`, fieldsToUpdate, ); } } else { console.log(" All user fields already exist with correct settings."); } } /** * 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, }); } /** * Sets up API rules for collections to allow user access. * Configures row-level security so users can only access their own records. */ export async function setupApiRules(pb: PocketBase): Promise { // Allow users to view any user record (needed for ICS calendar feed) // and update only their own record const usersCollection = await pb.collections.getOne("users"); await pb.collections.update(usersCollection.id, { viewRule: "", updateRule: "id = @request.auth.id", }); // Allow users to read/write their own period_logs const periodLogs = await pb.collections.getOne("period_logs"); await pb.collections.update(periodLogs.id, { listRule: "user = @request.auth.id", viewRule: "user = @request.auth.id", createRule: "user = @request.auth.id", updateRule: "user = @request.auth.id", deleteRule: "user = @request.auth.id", }); // Allow users to read/write their own dailyLogs const dailyLogs = await pb.collections.getOne("dailyLogs"); await pb.collections.update(dailyLogs.id, { listRule: "user = @request.auth.id", viewRule: "user = @request.auth.id", createRule: "user = @request.auth.id", updateRule: "user = @request.auth.id", deleteRule: "user = @request.auth.id", }); } /** * 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."); } else { 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); } } } // Set up API rules for all collections console.log("Configuring API rules..."); await setupApiRules(pb); console.log(" API rules configured."); 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); }); }