diff --git a/scripts/migrate-to-echo-life.ts b/scripts/migrate-to-echo-life.ts new file mode 100644 index 0000000..8f086f0 --- /dev/null +++ b/scripts/migrate-to-echo-life.ts @@ -0,0 +1,348 @@ +import { Prisma, PrismaClient } from "../src/generated/prisma/client.js"; +import { PrismaPg } from "@prisma/adapter-pg"; +import pg from "pg"; + +const ECHO_LIFE_ORG_ID = "cmo110dib000004kwa5nlxvwz"; + +const url = new URL(process.env.DATABASE_URL!); +url.searchParams.delete("sslmode"); +const pool = new pg.Pool({ + connectionString: url.toString(), + max: 5, + ssl: { rejectUnauthorized: false }, +}); +const prisma = new PrismaClient({ adapter: new PrismaPg(pool) }); + +async function main() { + console.log("=== Echo Life Church Data Migration ===\n"); + + // 1. Associate all cards with Echo Life Church + console.log("1. Associating cards with Echo Life Church..."); + const cardUpdate = await prisma.responseCard.updateMany({ + where: { organizationId: null }, + data: { organizationId: ECHO_LIFE_ORG_ID }, + }); + console.log(` Updated ${cardUpdate.count} cards\n`); + + // 2. Split name into firstName + lastName + console.log("2. Splitting name into firstName/lastName..."); + const cardsWithName = await prisma.responseCard.findMany({ + where: { name: { not: null }, firstName: null }, + select: { id: true, name: true }, + }); + let splitCount = 0; + for (const card of cardsWithName) { + if (!card.name) continue; + const parts = card.name.trim().split(/\s+/); + const firstName = parts[0] || ""; + const lastName = parts.slice(1).join(" ") || ""; + await prisma.responseCard.update({ + where: { id: card.id }, + data: { firstName, lastName }, + }); + splitCount++; + } + console.log(` Split ${splitCount} names\n`); + + // 3. Seed default form template + console.log("3. Seeding default Connect Card template..."); + const existingTemplate = await prisma.formTemplate.findFirst({ + where: { organizationId: ECHO_LIFE_ORG_ID, isDefault: true }, + }); + let templateId: string; + if (existingTemplate) { + templateId = existingTemplate.id; + console.log(` Template already exists: ${existingTemplate.id}\n`); + } else { + const DEFAULT_FIELDS = [ + { key: "firstName", label: "First Name", type: "text", section: "personal", required: true, removable: false, isCore: true, sortOrder: 0 }, + { key: "lastName", label: "Last Name", type: "text", section: "personal", required: true, removable: false, isCore: true, sortOrder: 1 }, + { key: "email", label: "Email", type: "email", section: "contact", sortOrder: 2 }, + { key: "cellPhone", label: "Cell Phone", type: "phone", section: "contact", sortOrder: 3 }, + { key: "homePhone", label: "Home Phone", type: "phone", section: "contact", sortOrder: 4 }, + { key: "gender", label: "Gender", type: "select", section: "personal", options: ["Male", "Female"], sortOrder: 5 }, + { key: "dateOfBirth", label: "Date of Birth", type: "date", section: "personal", sortOrder: 6 }, + { key: "maritalStatus", label: "Marital Status", type: "select", section: "personal", options: ["Married", "Single", "Other"], sortOrder: 7 }, + { key: "visitType", label: "Visit Type", type: "select", section: "survey", options: ["First/Second Time Guest", "Update My Information"], sortOrder: 8 }, + { key: "address", label: "Address", type: "text", section: "address", sortOrder: 9 }, + { key: "aptNumber", label: "Apt #", type: "text", section: "address", sortOrder: 10 }, + { key: "city", label: "City", type: "text", section: "address", sortOrder: 11 }, + { key: "state", label: "State", type: "text", section: "address", sortOrder: 12 }, + { key: "zip", label: "Zip", type: "text", section: "address", sortOrder: 13 }, + { key: "prayerRequests", label: "Prayer Requests", type: "textarea", section: "survey", sortOrder: 14 }, + { key: "prayerForTeam", label: "For Prayer Team", type: "checkbox", section: "survey", sortOrder: 15 }, + { key: "prayerConfidential", label: "Confidential", type: "checkbox", section: "survey", sortOrder: 16 }, + { key: "messageTopics", label: "Message Topics", type: "multiselect", section: "survey", options: ["Stress/Anxiety", "Marriage", "Hearing God's Voice", "Dealing With Doubt", "Parenting", "Grief & Loss", "Forgiveness", "Finances", "Purpose/Calling", "Prayer", "Healthy Boundaries", "Understanding The Bible", "Emotional Health", "Sharing My Faith", "Decision Making", "Spiritual Disciplines", "Spiritual Gifts"], sortOrder: 17 }, + { key: "nextStep", label: "Next Steps", type: "multiselect", section: "survey", options: ["Baptism", "Next Steps"], sortOrder: 18 }, + { key: "attendanceDuration", label: "Attendance Duration", type: "radio", section: "survey", options: ["Less than 6 months", "6 Months - 1 Year", "1-3 Years", "4-6 Years", "7+ Years"], sortOrder: 19 }, + { key: "campusPreference", label: "Campus Preference", type: "multiselect", section: "survey", options: ["Beulah", "Pace/Milton", "Gulf Breeze", "Warrington"], sortOrder: 20 }, + { key: "howHeard", label: "How Did You Hear About Us?", type: "multiselect", section: "survey", options: ["This is my church home", "Regular Attender", "Drove by", "Social Media", "Google", "Personal Invite"], sortOrder: 21 }, + { key: "serviceAttended", label: "Service Attended", type: "select", section: "survey", options: ["8:00 AM", "9:30 AM", "11:00 AM"], sortOrder: 22 }, + { key: "followUp", label: "Follow-Up", type: "text", section: "followup", sortOrder: 23 }, + { key: "notes", label: "Notes", type: "textarea", section: "followup", sortOrder: 24 }, + ]; + + const template = await prisma.formTemplate.create({ + data: { + organizationId: ECHO_LIFE_ORG_ID, + name: "Connect Card", + slug: "connect-card", + description: "Default connect card for Echo Life Church", + isDefault: true, + isActive: true, + fields: { + create: DEFAULT_FIELDS.map((f) => ({ + key: f.key, + label: f.label, + type: f.type, + section: f.section, + required: f.required ?? false, + removable: f.removable ?? true, + isCore: f.isCore ?? false, + sortOrder: f.sortOrder, + options: f.options ?? undefined, + })), + }, + }, + }); + templateId = template.id; + console.log(` Created template: ${template.id}\n`); + } + + // 4. Associate all cards with the default template + console.log("4. Linking cards to Connect Card template..."); + const templateLink = await prisma.responseCard.updateMany({ + where: { organizationId: ECHO_LIFE_ORG_ID, formTemplateId: null }, + data: { formTemplateId: templateId }, + }); + console.log(` Linked ${templateLink.count} cards\n`); + + // 5. Migrate legacy column data into fieldData JSON + console.log("5. Migrating legacy fields into fieldData..."); + const allCards = await prisma.responseCard.findMany({ + where: { organizationId: ECHO_LIFE_ORG_ID, fieldData: { equals: Prisma.DbNull } }, + select: { + id: true, gender: true, dateOfBirth: true, maritalStatus: true, + maritalStatusOther: true, visitType: true, homePhone: true, + address: true, aptNumber: true, city: true, state: true, zip: true, + prayerRequests: true, prayerForTeam: true, prayerConfidential: true, + messageTopics: true, messageTopicsOther: true, nextStep: true, + attendanceDuration: true, campusPreference: true, + campusPreferenceOther: true, howHeard: true, howHeardOther: true, + serviceAttended: true, followUp: true, notes: true, + }, + }); + let fieldDataCount = 0; + for (const card of allCards) { + const fd: Record = {}; + if (card.gender) fd.gender = card.gender; + if (card.dateOfBirth) fd.dateOfBirth = card.dateOfBirth; + if (card.maritalStatus) fd.maritalStatus = card.maritalStatus; + if (card.visitType) fd.visitType = card.visitType; + if (card.homePhone) fd.homePhone = card.homePhone; + if (card.address) fd.address = card.address; + if (card.aptNumber) fd.aptNumber = card.aptNumber; + if (card.city) fd.city = card.city; + if (card.state) fd.state = card.state; + if (card.zip) fd.zip = card.zip; + if (card.prayerRequests) fd.prayerRequests = card.prayerRequests; + if (card.prayerForTeam) fd.prayerForTeam = card.prayerForTeam; + if (card.prayerConfidential) fd.prayerConfidential = card.prayerConfidential; + if (card.messageTopics) fd.messageTopics = card.messageTopics; + if (card.nextStep) fd.nextStep = card.nextStep; + if (card.attendanceDuration) fd.attendanceDuration = card.attendanceDuration; + if (card.campusPreference) fd.campusPreference = card.campusPreference; + if (card.howHeard) fd.howHeard = card.howHeard; + if (card.serviceAttended) fd.serviceAttended = card.serviceAttended; + if (card.followUp) fd.followUp = card.followUp; + if (card.notes) fd.notes = card.notes; + if (Object.keys(fd).length > 0) { + await prisma.responseCard.update({ + where: { id: card.id }, + data: { fieldData: fd as Record }, + }); + fieldDataCount++; + } + } + console.log(` Migrated fieldData for ${fieldDataCount} cards\n`); + + // 6. Create location + 3 collection days (services) + console.log("6. Creating location and services..."); + const existingLocation = await prisma.location.findFirst({ + where: { organizationId: ECHO_LIFE_ORG_ID }, + }); + let locationId: string; + if (existingLocation) { + locationId = existingLocation.id; + console.log(` Location already exists: ${existingLocation.name}\n`); + } else { + const location = await prisma.location.create({ + data: { + organizationId: ECHO_LIFE_ORG_ID, + name: "Echo Life Church", + address: "Pensacola, FL", + timezone: "America/Chicago", + }, + }); + locationId = location.id; + console.log(` Created location: ${location.name}\n`); + } + + const existingDays = await prisma.collectionDay.count({ + where: { locationId }, + }); + if (existingDays > 0) { + console.log(` ${existingDays} collection days already exist, skipping\n`); + } else { + const services = [ + { name: "8:00 AM Service", dayOfWeek: 0, timeStart: "08:00", timeEnd: "09:15" }, + { name: "9:30 AM Service", dayOfWeek: 0, timeStart: "09:30", timeEnd: "10:45" }, + { name: "11:00 AM Service", dayOfWeek: 0, timeStart: "11:00", timeEnd: "12:15" }, + ]; + for (const svc of services) { + await prisma.collectionDay.create({ + data: { + locationId, + name: svc.name, + description: `Sunday ${svc.name}`, + dayOfWeek: svc.dayOfWeek, + timeStart: svc.timeStart, + timeEnd: svc.timeEnd, + isRecurring: true, + isActive: true, + }, + }); + } + console.log(` Created 3 Sunday services\n`); + } + + // 7. Add all users as members of Echo Life Church + console.log("7. Adding users to Echo Life Church..."); + const allUsers = await prisma.user.findMany({ select: { id: true, email: true } }); + const existingMembers = await prisma.orgMember.findMany({ + where: { organizationId: ECHO_LIFE_ORG_ID }, + select: { userId: true }, + }); + const existingMemberIds = new Set(existingMembers.map((m) => m.userId)); + + let added = 0; + for (const user of allUsers) { + if (existingMemberIds.has(user.id)) continue; + const role = user.email === "randall.stillwell@gmail.com" ? "admin" : "viewer"; + await prisma.orgMember.create({ + data: { userId: user.id, organizationId: ECHO_LIFE_ORG_ID, role }, + }); + added++; + } + console.log(` Added ${added} new members (${existingMembers.length} already existed)\n`); + + // 8. Set activeOrgId for users without one + console.log("8. Setting active org for users..."); + const usersNoOrg = await prisma.user.findMany({ + where: { activeOrgId: null }, + select: { id: true }, + }); + for (const u of usersNoOrg) { + await prisma.user.update({ + where: { id: u.id }, + data: { activeOrgId: ECHO_LIFE_ORG_ID }, + }); + } + console.log(` Set activeOrgId for ${usersNoOrg.length} users\n`); + + // 9. Populate People from card data + console.log("9. Creating People from card data..."); + const completeCards = await prisma.responseCard.findMany({ + where: { + organizationId: ECHO_LIFE_ORG_ID, + ocrStatus: "complete", + personId: null, + }, + select: { + id: true, + firstName: true, + lastName: true, + name: true, + email: true, + cellPhone: true, + fieldData: true, + }, + orderBy: { createdAt: "asc" }, + }); + + let peopleCreated = 0; + let peopleLinked = 0; + + for (const card of completeCards) { + const fName = card.firstName || card.name?.split(" ")[0] || ""; + const lName = card.lastName || card.name?.split(" ").slice(1).join(" ") || ""; + if (!fName && !lName) continue; + + const email = card.email?.toLowerCase() || null; + + // Try match by email first + let person = null; + if (email) { + person = await prisma.person.findFirst({ + where: { organizationId: ECHO_LIFE_ORG_ID, email, mergedIntoId: null }, + }); + } + + // Try match by name + if (!person && fName && lName) { + person = await prisma.person.findFirst({ + where: { + organizationId: ECHO_LIFE_ORG_ID, + firstName: { equals: fName, mode: "insensitive" }, + lastName: { equals: lName, mode: "insensitive" }, + mergedIntoId: null, + }, + }); + } + + if (person) { + await prisma.responseCard.update({ + where: { id: card.id }, + data: { personId: person.id }, + }); + peopleLinked++; + } else { + const newPerson = await prisma.person.create({ + data: { + organizationId: ECHO_LIFE_ORG_ID, + firstName: fName || "Unknown", + lastName: lName || "", + email, + cellPhone: card.cellPhone || null, + fieldData: (card.fieldData as object) || null, + }, + }); + await prisma.responseCard.update({ + where: { id: card.id }, + data: { personId: newPerson.id }, + }); + peopleCreated++; + } + } + console.log(` Created ${peopleCreated} people, linked ${peopleLinked} cards to existing people\n`); + + // 10. Summary + console.log("=== Migration Complete ==="); + const finalCards = await prisma.responseCard.count({ where: { organizationId: ECHO_LIFE_ORG_ID } }); + const finalPeople = await prisma.person.count({ where: { organizationId: ECHO_LIFE_ORG_ID } }); + const finalMembers = await prisma.orgMember.count({ where: { organizationId: ECHO_LIFE_ORG_ID } }); + const finalDays = await prisma.collectionDay.count({ where: { locationId } }); + console.log(`Cards: ${finalCards}`); + console.log(`People: ${finalPeople}`); + console.log(`Members: ${finalMembers}`); + console.log(`Services: ${finalDays}`); + console.log(`Template: ${templateId}`); + + await prisma.$disconnect(); + pool.end(); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/src/app/(auth)/invite/[token]/page.tsx b/src/app/(auth)/invite/[token]/page.tsx index 73a8968..b61ed69 100644 --- a/src/app/(auth)/invite/[token]/page.tsx +++ b/src/app/(auth)/invite/[token]/page.tsx @@ -1,24 +1,38 @@ "use client"; import { useEffect, useState } from "react"; -import { useParams, useRouter } from "next/navigation"; +import { useParams } from "next/navigation"; import Link from "next/link"; -import { ScanLine, Loader2, CheckCircle2, XCircle } from "lucide-react"; +import { ScanLine, Loader2, CheckCircle2, XCircle, LogIn } from "lucide-react"; import { Button } from "@/components/ui/button"; +import { useSession } from "next-auth/react"; +import { toast } from "sonner"; export default function InvitePage() { const params = useParams(); - const router = useRouter(); + const { data: session, status: sessionStatus } = useSession(); const token = params.token as string; - const [status, setStatus] = useState<"loading" | "valid" | "invalid">("loading"); - const [invitation, setInvitation] = useState<{ email: string; role: string } | null>(null); + const [status, setStatus] = useState<"loading" | "valid" | "invalid" | "accepted">("loading"); + const [invitation, setInvitation] = useState<{ + email: string; + role: string; + orgName: string; + hasExistingAccount: boolean; + } | null>(null); + const [accepting, setAccepting] = useState(false); + const [acceptedOrg, setAcceptedOrg] = useState(""); useEffect(() => { fetch(`/api/invitations/verify?token=${token}`) .then((r) => r.json()) .then((data) => { if (data.valid) { - setInvitation({ email: data.email, role: data.role }); + setInvitation({ + email: data.email, + role: data.role, + orgName: data.orgName, + hasExistingAccount: data.hasExistingAccount, + }); setStatus("valid"); } else { setStatus("invalid"); @@ -27,7 +41,36 @@ export default function InvitePage() { .catch(() => setStatus("invalid")); }, [token]); - if (status === "loading") { + const isLoggedIn = sessionStatus === "authenticated" && session?.user; + const emailMatch = isLoggedIn && session.user.email?.toLowerCase() === invitation?.email.toLowerCase(); + + async function handleAcceptInvite() { + setAccepting(true); + try { + const res = await fetch("/api/invitations/accept", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token }), + }); + const data = await res.json(); + if (!res.ok) { + toast.error(data.error || "Failed to accept invitation"); + setAccepting(false); + return; + } + setAcceptedOrg(data.orgName); + setStatus("accepted"); + await fetch("/api/auth/session"); + setTimeout(() => { + window.location.href = "/"; + }, 1500); + } catch { + toast.error("Failed to accept invitation"); + setAccepting(false); + } + } + + if (status === "loading" || sessionStatus === "loading") { return (
@@ -36,6 +79,19 @@ export default function InvitePage() { ); } + if (status === "accepted") { + return ( +
+ +

Welcome!

+

+ You've joined {acceptedOrg}. Redirecting... +

+ +
+ ); + } + if (status === "invalid") { return (
@@ -44,13 +100,9 @@ export default function InvitePage() {

This invitation link is invalid, expired, or has already been used.

- + + +
); } @@ -63,14 +115,56 @@ export default function InvitePage() {

You're Invited

- You've been invited to join as a {invitation?.role}. + You've been invited to join {invitation?.orgName} as + a {invitation?.role}.

Invitation for: {invitation?.email}

- - - + +
+ {isLoggedIn && emailMatch ? ( + + ) : isLoggedIn && !emailMatch ? ( + <> +

+ You're signed in as {session?.user?.email}, but + this invitation is for {invitation?.email}. + Please sign out and sign in with the correct account. +

+ + + + + ) : invitation?.hasExistingAccount ? ( + <> + + + +

+ An account with this email already exists. Sign in to accept the invitation. +

+ + ) : ( + + + + )} +
); } diff --git a/src/app/api/invitations/accept/route.ts b/src/app/api/invitations/accept/route.ts new file mode 100644 index 0000000..6b54e42 --- /dev/null +++ b/src/app/api/invitations/accept/route.ts @@ -0,0 +1,78 @@ +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@/auth"; +import { prisma } from "@/lib/db"; + +export async function POST(req: NextRequest) { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { token } = await req.json(); + if (!token) { + return NextResponse.json({ error: "Token is required" }, { status: 400 }); + } + + const invitation = await prisma.invitation.findUnique({ + where: { token }, + include: { organization: { select: { id: true, name: true } } }, + }); + + if (!invitation || invitation.acceptedAt || invitation.expiresAt < new Date()) { + return NextResponse.json({ error: "Invalid or expired invitation" }, { status: 400 }); + } + + if (invitation.email.toLowerCase() !== session.user.email?.toLowerCase()) { + return NextResponse.json( + { error: "This invitation was sent to a different email address" }, + { status: 403 } + ); + } + + const existingMember = await prisma.orgMember.findUnique({ + where: { + userId_organizationId: { + userId: session.user.id, + organizationId: invitation.organizationId, + }, + }, + }); + + if (existingMember) { + await prisma.invitation.update({ + where: { id: invitation.id }, + data: { acceptedAt: new Date() }, + }); + return NextResponse.json({ + success: true, + alreadyMember: true, + orgId: invitation.organizationId, + orgName: invitation.organization.name, + }); + } + + await prisma.$transaction([ + prisma.orgMember.create({ + data: { + userId: session.user.id, + organizationId: invitation.organizationId, + role: invitation.role, + }, + }), + prisma.invitation.update({ + where: { id: invitation.id }, + data: { acceptedAt: new Date() }, + }), + prisma.user.update({ + where: { id: session.user.id }, + data: { activeOrgId: invitation.organizationId }, + }), + ]); + + return NextResponse.json({ + success: true, + alreadyMember: false, + orgId: invitation.organizationId, + orgName: invitation.organization.name, + }); +} diff --git a/src/app/api/invitations/verify/route.ts b/src/app/api/invitations/verify/route.ts index 6dd79f8..ee24083 100644 --- a/src/app/api/invitations/verify/route.ts +++ b/src/app/api/invitations/verify/route.ts @@ -9,15 +9,23 @@ export async function GET(req: NextRequest) { const invitation = await prisma.invitation.findUnique({ where: { token }, + include: { organization: { select: { name: true } } }, }); if (!invitation || invitation.acceptedAt || invitation.expiresAt < new Date()) { return NextResponse.json({ valid: false }); } + const existingUser = await prisma.user.findUnique({ + where: { email: invitation.email }, + select: { id: true }, + }); + return NextResponse.json({ valid: true, email: invitation.email, role: invitation.role, + orgName: invitation.organization.name, + hasExistingAccount: !!existingUser, }); }