Files
phaseflow/scripts/setup-db.ts
Petru Paler 27f084f950
All checks were successful
Deploy / deploy (push) Successful in 1m38s
Fix Garmin token connection not persisting after save
Root cause: The setup-db script was missing user field definitions
(garminConnected, tokens, etc.). Production PocketBase had no such
fields, so updates silently failed to persist.

Changes:
- Add user custom fields to setup-db.ts (matches e2e harness)
- Fix status route to use strict boolean check (=== true)
- Add verification in tokens route with helpful error message
- Add ENCRYPTION_KEY to playwright config for e2e tests
- Add comprehensive e2e tests for Garmin connection flow

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 13:20:50 +00:00

343 lines
8.6 KiB
TypeScript

// 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<void> {
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<string[]> {
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<string> {
const collection = await pb.collections.getOne(nameOrId);
return collection.id;
}
/**
* Creates a collection in PocketBase.
*/
export async function createCollection(
pb: PocketBase,
collection: CollectionDefinition,
): Promise<void> {
// Resolve any collection names to actual IDs for relation fields
const fields = await Promise.all(
collection.fields.map(async (field) => {
const baseField: Record<string, unknown> = {
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<void> {
// 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);
});
}