Files
phaseflow/scripts/setup-db.ts
Petru Paler 0579ca2534
All checks were successful
Deploy / deploy (push) Successful in 2m29s
Add API rules setup to database initialization
The period_logs collection was returning 403 errors because API rules
were only configured in the e2e test harness, not in the production
setup script. This consolidates the setup logic so both prod and test
use the same setupApiRules() function.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 06:49:42 +00:00

410 lines
11 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.
*/
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" },
];
/**
* 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<void> {
const usersCollection = await pb.collections.getOne("users");
// Build a map of existing fields by name
const existingFieldsMap = new Map<string, Record<string, unknown>>(
(usersCollection.fields || []).map((f: Record<string, unknown>) => [
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<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,
});
}
/**
* 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<void> {
// 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<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.");
} 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);
});
}