inspannings-monitor/scripts/seed-demo-users.mjs

460 lines
12 KiB
JavaScript

import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { createClient } from "@supabase/supabase-js";
import { demoPersonas } from "./seed/demo-personas.mjs";
import { demoUsageSeeds } from "./seed/demo-usage-seeds.mjs";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const rootDir = path.resolve(__dirname, "..");
async function loadEnvFile(filePath) {
try {
const rawContents = await fs.readFile(filePath, "utf8");
for (const line of rawContents.split(/\r?\n/)) {
const trimmedLine = line.trim();
if (!trimmedLine || trimmedLine.startsWith("#")) {
continue;
}
const separatorIndex = trimmedLine.indexOf("=");
if (separatorIndex < 0) {
continue;
}
const key = trimmedLine.slice(0, separatorIndex).trim();
const rawValue = trimmedLine.slice(separatorIndex + 1).trim();
if (!key || process.env[key] !== undefined) {
continue;
}
const normalizedValue = rawValue.replace(/^['"]|['"]$/g, "");
process.env[key] = normalizedValue;
}
} catch (error) {
if (error instanceof Error && "code" in error && error.code === "ENOENT") {
return;
}
throw error;
}
}
await loadEnvFile(path.join(rootDir, ".env.local"));
await loadEnvFile(path.join(rootDir, ".env"));
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const serviceRoleKey =
process.env.SUPABASE_SECRET_KEY ??
process.env.SUPABASE_SERVICE_ROLE_KEY ??
process.env.NEXT_PUBLIC_SUPABASE_SERVICE_KEY;
const demoUserPassword = process.env.DEMO_USER_PASSWORD;
const isDryRun = process.argv.includes("--dry-run");
if (!supabaseUrl || !serviceRoleKey) {
throw new Error(
"Zet NEXT_PUBLIC_SUPABASE_URL en een admin key (liefst SUPABASE_SECRET_KEY, anders SUPABASE_SERVICE_ROLE_KEY) in je omgeving voordat je demo-users seedt.",
);
}
if (!process.env.SUPABASE_SECRET_KEY && process.env.NEXT_PUBLIC_SUPABASE_SERVICE_KEY) {
console.warn(
"Let op: het seedscript gebruikt nu NEXT_PUBLIC_SUPABASE_SERVICE_KEY als fallback. Gebruik liever SUPABASE_SECRET_KEY in .env.local voor lokale admin-taken.",
);
}
if (!demoUserPassword && !isDryRun) {
throw new Error(
"Zet DEMO_USER_PASSWORD in je omgeving of gebruik --dry-run om alleen te controleren wat er zou gebeuren.",
);
}
const supabase = createClient(supabaseUrl, serviceRoleKey, {
auth: {
autoRefreshToken: false,
persistSession: false,
},
});
const BUDGET_FORMULA_VERSION = 1;
function deriveEnergyLevel(energyScore) {
if (energyScore <= 2) {
return "zeer_laag";
}
if (energyScore <= 4) {
return "laag";
}
if (energyScore <= 6) {
return "midden";
}
if (energyScore <= 8) {
return "redelijk";
}
return "hoog";
}
function deriveBudgetSnapshot(energyScore) {
return {
energy_level: deriveEnergyLevel(energyScore),
daily_budget: energyScore,
budget_formula_version: BUDGET_FORMULA_VERSION,
};
}
function formatDatePart(value) {
return String(value).padStart(2, "0");
}
function getDatePartsInTimezone(date, timezone) {
const formatter = new Intl.DateTimeFormat("en-CA", {
timeZone: timezone,
year: "numeric",
month: "2-digit",
day: "2-digit",
});
const parts = formatter.formatToParts(date);
const year = parts.find((part) => part.type === "year")?.value;
const month = parts.find((part) => part.type === "month")?.value;
const day = parts.find((part) => part.type === "day")?.value;
if (!year || !month || !day) {
throw new Error(`Kon lokale datum niet bepalen voor timezone ${timezone}.`);
}
return { year, month, day };
}
function getDateStringWithOffset(timezone, dayOffset) {
const now = new Date();
const { year, month, day } = getDatePartsInTimezone(now, timezone);
const baseDate = new Date(Date.UTC(Number(year), Number(month) - 1, Number(day)));
baseDate.setUTCDate(baseDate.getUTCDate() + dayOffset);
return [
baseDate.getUTCFullYear(),
formatDatePart(baseDate.getUTCMonth() + 1),
formatDatePart(baseDate.getUTCDate()),
].join("-");
}
function getAvatarPath(userId) {
return `${userId}/avatar`;
}
async function loadExistingUsersByEmail() {
const byEmail = new Map();
let page = 1;
const perPage = 200;
while (true) {
const { data, error } = await supabase.auth.admin.listUsers({ page, perPage });
if (error) {
throw new Error(`Kon bestaande auth-users niet laden: ${error.message}`);
}
for (const user of data.users) {
if (user.email) {
byEmail.set(user.email.toLowerCase(), user);
}
}
if (data.users.length < perPage) {
break;
}
page += 1;
}
return byEmail;
}
async function ensureAuthUser(persona, existingUsersByEmail) {
const existingUser = existingUsersByEmail.get(persona.email.toLowerCase());
if (existingUser) {
const { error } = await supabase.auth.admin.updateUserById(existingUser.id, {
email_confirm: true,
user_metadata: {
display_name: persona.displayName,
demo_user: true,
},
});
if (error) {
throw new Error(`Kon auth-user ${persona.email} niet bijwerken: ${error.message}`);
}
return existingUser.id;
}
if (isDryRun) {
return `dry-run-${persona.slug}`;
}
const { data, error } = await supabase.auth.admin.createUser({
email: persona.email,
password: demoUserPassword,
email_confirm: true,
user_metadata: {
display_name: persona.displayName,
demo_user: true,
},
app_metadata: {
demo_user: true,
},
});
if (error || !data.user) {
throw new Error(`Kon auth-user ${persona.email} niet aanmaken: ${error?.message ?? "onbekend"}`);
}
existingUsersByEmail.set(persona.email.toLowerCase(), data.user);
return data.user.id;
}
async function uploadAvatarIfPresent(userId, persona) {
if (!persona.avatarFile || isDryRun) {
return null;
}
const avatarFilePath = path.join(rootDir, persona.avatarFile);
const avatarBytes = await fs.readFile(avatarFilePath);
const avatarPath = getAvatarPath(userId);
const { error } = await supabase.storage
.from("profile-avatars")
.upload(avatarPath, avatarBytes, {
upsert: true,
contentType: "image/jpeg",
cacheControl: "3600",
});
if (error) {
throw new Error(`Kon avatar voor ${persona.email} niet uploaden: ${error.message}`);
}
return avatarPath;
}
async function upsertProfile(userId, persona, avatarPath) {
if (isDryRun) {
return;
}
const { error } = await supabase.from("profiles").upsert(
{
id: userId,
email: persona.email,
display_name: persona.displayName,
tagline: persona.tagline,
bio: persona.bio,
avatar_path: avatarPath,
locale: persona.locale,
timezone: persona.timezone,
onboarding_seen: true,
onboarding_completed: true,
},
{ onConflict: "id" },
);
if (error) {
throw new Error(`Kon profiel ${persona.email} niet opslaan: ${error.message}`);
}
}
async function upsertSettings(userId, persona) {
if (isDryRun) {
return;
}
const { error } = await supabase.from("user_settings").upsert(
{
profile_id: userId,
morning_reminder_enabled: persona.settings.morningReminderEnabled,
morning_reminder_time: persona.settings.morningReminderTime,
reflection_reminder_enabled: persona.settings.reflectionReminderEnabled,
show_energy_points: persona.settings.showEnergyPoints,
},
{ onConflict: "profile_id" },
);
if (error) {
throw new Error(`Kon settings voor ${persona.email} niet opslaan: ${error.message}`);
}
}
async function loadReferenceMap(tableName) {
const { data, error } = await supabase.from(tableName).select("id, key");
if (error) {
throw new Error(`Kon referentiedata uit ${tableName} niet laden: ${error.message}`);
}
return new Map(data.map((row) => [row.key, row.id]));
}
async function seedUsageData(userId, persona) {
const usageSeed = demoUsageSeeds[persona.email];
if (!usageSeed) {
return {
checkIns: 0,
activities: 0,
};
}
const categoryIdsByKey = await loadReferenceMap("activity_categories");
const skipReasonIdsByKey = await loadReferenceMap("skip_reasons");
const checkIns = usageSeed.checkIns.map((entry) => {
const checkInDate = getDateStringWithOffset(persona.timezone, entry.dayOffset);
return {
user_id: userId,
check_in_date: checkInDate,
energy_score: entry.energyScore,
sleep_quality: entry.sleepQuality,
...deriveBudgetSnapshot(entry.energyScore),
};
});
const activityDateMap = new Map();
for (const dayGroup of usageSeed.activities) {
activityDateMap.set(
dayGroup.dayOffset,
getDateStringWithOffset(persona.timezone, dayGroup.dayOffset),
);
}
if (isDryRun) {
return {
checkIns: checkIns.length,
activities: usageSeed.activities.reduce(
(count, dayGroup) => count + dayGroup.items.length,
0,
),
};
}
if (checkIns.length > 0) {
const { error } = await supabase.from("morning_check_ins").upsert(checkIns, {
onConflict: "user_id,check_in_date",
});
if (error) {
throw new Error(`Kon check-ins voor ${persona.email} niet seeden: ${error.message}`);
}
}
const seededDates = [...new Set([...activityDateMap.values()])];
if (seededDates.length > 0) {
const { error: deleteError } = await supabase
.from("activities")
.delete()
.eq("user_id", userId)
.in("activity_date", seededDates);
if (deleteError) {
throw new Error(`Kon bestaande activiteiten voor ${persona.email} niet vervangen: ${deleteError.message}`);
}
}
const activityRows = usageSeed.activities.flatMap((dayGroup) => {
const activityDate = activityDateMap.get(dayGroup.dayOffset);
return dayGroup.items.map((item) => {
const categoryId = categoryIdsByKey.get(item.categoryKey);
if (!categoryId) {
throw new Error(
`Onbekende activity category key '${item.categoryKey}' voor ${persona.email}.`,
);
}
const skipReasonId = item.skipReasonKey
? skipReasonIdsByKey.get(item.skipReasonKey)
: null;
if (item.skipReasonKey && !skipReasonId) {
throw new Error(
`Onbekende skip reason key '${item.skipReasonKey}' voor ${persona.email}.`,
);
}
return {
user_id: userId,
activity_date: activityDate,
source: item.source,
status: item.status,
name: item.name,
category_id: categoryId,
duration_minutes: item.durationMinutes,
impact_level: item.impactLevel,
priority_level: item.priorityLevel,
skip_reason_id: skipReasonId,
notes: item.notes ?? null,
};
});
});
if (activityRows.length > 0) {
const { error } = await supabase.from("activities").insert(activityRows);
if (error) {
throw new Error(`Kon activiteiten voor ${persona.email} niet seeden: ${error.message}`);
}
}
return {
checkIns: checkIns.length,
activities: activityRows.length,
};
}
async function main() {
const existingUsersByEmail = await loadExistingUsersByEmail();
const results = [];
for (const persona of demoPersonas) {
const userId = await ensureAuthUser(persona, existingUsersByEmail);
const avatarPath = await uploadAvatarIfPresent(userId, persona);
await upsertProfile(userId, persona, avatarPath);
await upsertSettings(userId, persona);
const usageResult = await seedUsageData(userId, persona);
results.push({
email: persona.email,
userId,
avatar: avatarPath ? "ja" : "nee",
checkIns: usageResult.checkIns,
activities: usageResult.activities,
dryRun: isDryRun ? "ja" : "nee",
});
}
console.table(results);
console.log(
isDryRun
? "Dry run klaar. Er zijn geen wijzigingen weggeschreven."
: "Demo-gebruikers, profielen, settings, avatars en usage-seeds zijn gesynchroniseerd.",
);
}
main().catch((error) => {
console.error(error instanceof Error ? error.message : error);
process.exitCode = 1;
});