echos-ocr/prisma/seed-migration.ts
Randall Stillwell d3e7374439 Add SaaS foundation: Auth.js, dashboard shell, org model, auto-assignment
Major architectural upgrade preparing Echo OCR for self-hosted SaaS deployment:

- Auth: Built-in Auth.js v5 with credentials + Authentik OIDC SSO, JWT sessions,
  middleware route protection, login/signup/setup pages, registration API
- UI: Dashboard layout with collapsible sidebar nav, AppShell wrapper, route
  groups for (dashboard) and (auth), new pages for events/people/reports
- Schema: Auth.js tables (Account, Session, VerificationToken), Organization,
  OrgMember, Location, CollectionDay, Invitation, SystemConfig, ApiKey models;
  proper User relations to ResponseCard/ActivityLog/Notification
- Permissions: Role hierarchy (owner/admin/editor/reviewer/viewer) with
  action-based permission map and requirePermission/requireAuth helpers
- Onboarding: Multi-step setup wizard for first-user bootstrap (account, org,
  location) with SystemConfig tracking
- Events: CollectionDay model with rrule support for recurring church services
- Auto-assign: Event-aware card assignment engine replacing getPreviousSunday()
- Migration: seed-migration.ts script for upgrading existing deployments

Made-with: Cursor
2026-04-14 23:59:38 -05:00

180 lines
5 KiB
TypeScript

/**
* Data migration script for existing deployments upgrading to v2.
*
* Run with: npx tsx prisma/seed-migration.ts
*
* This script:
* 1. Creates a SystemConfig singleton (setup complete)
* 2. Creates a default Organization from existing data
* 3. Creates a default Location
* 4. Creates a default "Sunday Service" CollectionDay
* 5. Migrates existing Users into OrgMembers
* 6. Backfills organizationId and locationId on ResponseCards
* 7. Backfills collectionDayId using existing dates
*/
import "dotenv/config";
import { PrismaClient } from "../src/generated/prisma/client";
import { PrismaPg } from "@prisma/adapter-pg";
import pg from "pg";
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
const adapter = new PrismaPg(pool);
const prisma = new PrismaClient({ adapter });
async function main() {
console.log("Starting v2 data migration...\n");
// 1. SystemConfig
const existingConfig = await prisma.systemConfig.findUnique({
where: { id: "singleton" },
});
if (existingConfig?.isSetupComplete) {
console.log("Setup already marked complete. Skipping migration.");
return;
}
await prisma.systemConfig.upsert({
where: { id: "singleton" },
update: { isSetupComplete: true, setupStep: 3 },
create: { id: "singleton", isSetupComplete: true, setupStep: 3 },
});
console.log("[1/7] SystemConfig created (setup complete)");
// 2. Default Organization
let org = await prisma.organization.findFirst();
if (!org) {
org = await prisma.organization.create({
data: {
name: "My Church",
slug: "my-church",
type: "church",
timezone: "America/Chicago",
},
});
console.log(`[2/7] Created default organization: ${org.name} (${org.id})`);
} else {
console.log(`[2/7] Organization already exists: ${org.name}`);
}
// 3. Default Location
let location = await prisma.location.findFirst({
where: { organizationId: org.id },
});
if (!location) {
location = await prisma.location.create({
data: {
name: "Main Campus",
organizationId: org.id,
},
});
console.log(`[3/7] Created default location: ${location.name} (${location.id})`);
} else {
console.log(`[3/7] Location already exists: ${location.name}`);
}
// 4. Default CollectionDay
let collectionDay = await prisma.collectionDay.findFirst({
where: { locationId: location.id },
});
if (!collectionDay) {
collectionDay = await prisma.collectionDay.create({
data: {
locationId: location.id,
name: "Sunday Service",
dayOfWeek: 0,
timeStart: "09:00",
timeEnd: "12:00",
isRecurring: true,
},
});
console.log(`[4/7] Created default collection day: ${collectionDay.name}`);
} else {
console.log(`[4/7] Collection day already exists: ${collectionDay.name}`);
}
// 5. Migrate Users to OrgMembers
const users = await prisma.user.findMany();
let memberCount = 0;
for (const user of users) {
const existing = await prisma.orgMember.findUnique({
where: {
userId_organizationId: {
userId: user.id,
organizationId: org.id,
},
},
});
if (!existing) {
await prisma.orgMember.create({
data: {
userId: user.id,
organizationId: org.id,
role: user.role || "viewer",
},
});
memberCount++;
}
}
console.log(`[5/7] Migrated ${memberCount} users to org members (${users.length} total users)`);
// 6. Backfill ResponseCard.organizationId and locationId
const cardResult = await prisma.responseCard.updateMany({
where: { organizationId: null },
data: {
organizationId: org.id,
locationId: location.id,
},
});
console.log(`[6/7] Backfilled ${cardResult.count} cards with org/location`);
// 7. Backfill collectionDayId
const cardsWithDates = await prisma.responseCard.findMany({
where: {
collectionDayId: null,
OR: [
{ firstTimeGuestDate: { not: null } },
{ createdAt: { not: undefined } },
],
},
select: { id: true, firstTimeGuestDate: true, createdAt: true },
});
let assignedCount = 0;
for (const card of cardsWithDates) {
const refDate = card.firstTimeGuestDate || card.createdAt;
const sunday = new Date(refDate);
const day = sunday.getDay();
if (day !== 0) sunday.setDate(sunday.getDate() - day);
sunday.setHours(0, 0, 0, 0);
await prisma.responseCard.update({
where: { id: card.id },
data: {
collectionDayId: collectionDay.id,
collectionDate: sunday,
},
});
assignedCount++;
}
console.log(`[7/7] Assigned ${assignedCount} cards to default collection day`);
// 8. Backfill ProcessingJob.organizationId
await prisma.processingJob.updateMany({
where: { organizationId: null },
data: { organizationId: org.id },
});
console.log("\nMigration complete!");
}
main()
.catch((e) => {
console.error("Migration failed:", e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
await pool.end();
});