diff --git a/.env.template b/.env.template index 83e39beb..ea20fd71 100644 --- a/.env.template +++ b/.env.template @@ -16,8 +16,16 @@ DB_USER=test DB_PASSWORD=test DB_NAME=scriptio -# Staging Basic Auth (htpasswd format, escape $ as $$ in this file) +# Basic Auth (htpasswd format, escape $ as $$ in this file) +PROD_AUTH_USERS= STAGING_AUTH_USERS= +MONITORING_AUTH_USERS= + +# Monitoring +GRAFANA_ADMIN_PASSWORD= +METRICS_BEARER_TOKEN= +APP_MEMORY_LIMIT_BYTES=536870912 # 512 MiB; tune to the prod container's memory budget +DB_SIZE_LIMIT_BYTES=10737418240 # 10 GiB; tune to the prod DB's expected ceiling # S3 storage S3_BUCKET= diff --git a/Dockerfile b/Dockerfile index 3fbba538..0db35525 100644 --- a/Dockerfile +++ b/Dockerfile @@ -26,5 +26,5 @@ EXPOSE 3000 USER node ENV NEXT_TELEMETRY_DISABLED 1 -ENTRYPOINT ["./launch.sh"] +ENTRYPOINT ["./scripts/launch.sh"] CMD [ "npm", "start" ] \ No newline at end of file diff --git a/components/admin/AdminShell.module.css b/components/admin/AdminShell.module.css new file mode 100644 index 00000000..07024496 --- /dev/null +++ b/components/admin/AdminShell.module.css @@ -0,0 +1,133 @@ +.wrapper { + display: flex; + width: 100%; + height: 100vh; + background: var(--main-bg); +} + +.sidebar { + width: 240px; + flex-shrink: 0; + background: var(--secondary); + border-right: 1px solid var(--separator); + display: flex; + flex-direction: column; + padding: 24px 16px; + gap: 24px; +} + +.brand { + display: flex; + align-items: center; + gap: 10px; + padding: 0 8px; + color: var(--primary-text); + font-family: var(--font-josefin), sans-serif; + font-weight: 700; + font-size: 18px; + letter-spacing: 0.02em; +} + +.brandBadge { + display: inline-flex; + align-items: center; + padding: 2px 8px; + border-radius: 999px; + background: var(--tertiary); + color: var(--primary-text); + font-size: 10px; + letter-spacing: 0.08em; + text-transform: uppercase; + font-family: var(--font-inter), sans-serif; + font-weight: 600; +} + +.nav { + display: flex; + flex-direction: column; + gap: 2px; +} + +.navGroupLabel { + font-family: var(--font-inter), sans-serif; + font-size: 11px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--secondary-text); + padding: 12px 12px 4px; +} + +.navItem { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + background: transparent; + border: none; + border-radius: 8px; + color: var(--primary-text); + font-family: var(--font-inter), sans-serif; + font-size: 14px; + cursor: pointer; + text-align: left; + text-decoration: none; + transition: background-color 120ms ease; +} + +.navItem:hover { + background: var(--primary-hover); +} + +.navItemActive { + background: var(--tertiary); +} + +.navItemActive:hover { + background: var(--tertiary-hover); +} + +.footer { + margin-top: auto; + padding: 12px; + border-top: 1px solid var(--separator); + color: var(--secondary-text); + font-size: 12px; + font-family: var(--font-inter), sans-serif; +} + +.footerEmail { + color: var(--primary-text); + font-weight: 600; + display: block; + margin-bottom: 2px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.main { + flex: 1; + overflow-y: auto; + padding: 32px 40px; + min-width: 0; +} + +.header { + display: flex; + align-items: baseline; + justify-content: space-between; + margin-bottom: 24px; +} + +.title { + color: var(--primary-text); + font-family: var(--font-josefin), sans-serif; + font-size: 28px; + font-weight: 700; +} + +.subtitle { + color: var(--secondary-text); + font-family: var(--font-inter), sans-serif; + font-size: 13px; +} diff --git a/components/admin/AdminShell.tsx b/components/admin/AdminShell.tsx new file mode 100644 index 00000000..a0db4ec0 --- /dev/null +++ b/components/admin/AdminShell.tsx @@ -0,0 +1,72 @@ +"use client"; + +import { ReactNode } from "react"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { BarChart3, Users, FolderOpen, LogOut } from "lucide-react"; +import styles from "./AdminShell.module.css"; + +type NavLink = { href: string; label: string; icon: ReactNode }; + +const NAV_LINKS: NavLink[] = [ + { href: "/admin", label: "Overview", icon: }, + { href: "/admin/users", label: "Users", icon: }, + { href: "/admin/projects", label: "Projects", icon: }, +]; + +type Props = { + email: string; + title: string; + subtitle?: string; + children: ReactNode; +}; + +export default function AdminShell({ email, title, subtitle, children }: Props) { + const pathname = usePathname() ?? ""; + + return ( +
+ +
+
+

{title}

+ {subtitle && {subtitle}} +
+ {children} +
+
+ ); +} diff --git a/components/admin/ProjectDetail.module.css b/components/admin/ProjectDetail.module.css new file mode 100644 index 00000000..eace3676 --- /dev/null +++ b/components/admin/ProjectDetail.module.css @@ -0,0 +1,137 @@ +.container { + display: flex; + flex-direction: column; + gap: 24px; +} + +.backLink { + display: inline-flex; + align-items: center; + gap: 6px; + color: var(--secondary-text); + text-decoration: none; + font-family: var(--font-inter), sans-serif; + font-size: 13px; + margin-bottom: 8px; + width: fit-content; +} + +.backLink:hover { + color: var(--primary-text); +} + +.card { + background: var(--secondary); + border: 1px solid var(--separator); + border-radius: 12px; + padding: 24px; + box-shadow: var(--panel-shadow); +} + +.cardTitle { + color: var(--primary-text); + font-family: var(--font-josefin), sans-serif; + font-size: 18px; + font-weight: 700; + margin-bottom: 16px; +} + +.fields { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 16px 24px; +} + +.field { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; +} + +.fieldLabel { + color: var(--secondary-text); + font-family: var(--font-inter), sans-serif; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.fieldValue { + color: var(--primary-text); + font-family: var(--font-inter), sans-serif; + font-size: 14px; + overflow-wrap: anywhere; +} + +.idValue { + font-family: var(--font-courier), monospace; + font-size: 13px; +} + +.table { + display: flex; + flex-direction: column; + border: 1px solid var(--separator); + border-radius: 8px; + overflow: hidden; +} + +.tableRow { + display: grid; + grid-template-columns: 2fr 1fr; + gap: 16px; + padding: 12px 16px; + font-family: var(--font-inter), sans-serif; + font-size: 13px; + color: var(--primary-text); + border-bottom: 1px solid var(--separator); + align-items: center; +} + +.tableRow:last-child { + border-bottom: none; +} + +.tableHeader { + background: var(--primary); + color: var(--secondary-text); + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.06em; + font-weight: 600; +} + +.tableRowLink { + text-decoration: none; + transition: background-color 120ms ease; + cursor: pointer; +} + +.tableRowLink:hover { + background: var(--primary-hover); +} + +.tableCellMuted { + color: var(--secondary-text); + font-size: 12px; +} + +.emptyRow { + padding: 16px; + text-align: center; + color: var(--secondary-text); + font-family: var(--font-inter), sans-serif; + font-size: 13px; +} + +.message { + color: var(--secondary-text); + font-family: var(--font-inter), sans-serif; + font-size: 14px; +} + +.error { + color: var(--error); +} diff --git a/components/admin/ProjectDetail.tsx b/components/admin/ProjectDetail.tsx new file mode 100644 index 00000000..fb538644 --- /dev/null +++ b/components/admin/ProjectDetail.tsx @@ -0,0 +1,156 @@ +"use client"; + +import Link from "next/link"; +import useSWR from "swr"; +import { ArrowLeft } from "lucide-react"; +import { ProjectRole } from "../../src/generated/client/browser"; +import styles from "./ProjectDetail.module.css"; + +type ProjectDetailPayload = { + id: string; + title: string; + author: string | null; + description: string | null; + createdAt: string; + updatedAt: string; + _count: { members: number; invitations: number }; +}; + +type Member = { + role: ProjectRole; + user: { id: string; email: string }; +}; + +type Invite = { + email: string; + createdAt: string; +}; + +function formatDateTime(iso: string) { + return new Date(iso).toLocaleString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +} + +type Props = { projectId: string }; + +export default function ProjectDetail({ projectId }: Props) { + const { data, error, isLoading } = useSWR( + `/api/admin/projects/${projectId}`, + ); + const { data: members } = useSWR( + `/api/admin/projects/${projectId}/members`, + ); + const { data: invites } = useSWR( + `/api/admin/projects/${projectId}/invites`, + ); + + return ( +
+ + Back to search + + + {isLoading &&

Loading project…

} + {error &&

Project not found.

} + + {data && ( + <> +
+

Project

+
+
+ Title + {data.title} +
+
+ Project ID + + {data.id} + +
+
+ Author + {data.author ?? "—"} +
+
+ Description + {data.description ?? "—"} +
+
+ Created + + {formatDateTime(data.createdAt)} + +
+
+ Last updated + + {formatDateTime(data.updatedAt)} + +
+
+
+ +
+

+ Members ({data._count.members}) +

+
+
+ Email + Role +
+ {!members && ( +
Loading…
+ )} + {members && members.length === 0 && ( +
No members.
+ )} + {members?.map((m) => ( + + {m.user.email} + {m.role} + + ))} +
+
+ +
+

+ Pending invitations ({data._count.invitations}) +

+
+
+ Email + Invited +
+ {!invites && ( +
Loading…
+ )} + {invites && invites.length === 0 && ( +
No pending invitations.
+ )} + {invites?.map((inv) => ( +
+ {inv.email} + + {formatDateTime(inv.createdAt)} + +
+ ))} +
+
+ + )} +
+ ); +} diff --git a/components/admin/ProjectSearch.module.css b/components/admin/ProjectSearch.module.css new file mode 100644 index 00000000..484ecf66 --- /dev/null +++ b/components/admin/ProjectSearch.module.css @@ -0,0 +1,104 @@ +.container { + display: flex; + flex-direction: column; + gap: 20px; +} + +.searchBar { + display: flex; + align-items: center; + gap: 10px; + background: var(--secondary); + border: 1px solid var(--input-outline); + border-radius: 10px; + padding: 10px 14px; + box-shadow: var(--panel-shadow); +} + +.searchBar:focus-within { + border-color: var(--primary-text); +} + +.searchInput { + flex: 1; + background: transparent; + border: none; + outline: none; + color: var(--primary-text); + font-family: var(--font-inter), sans-serif; + font-size: 14px; +} + +.hint { + color: var(--secondary-text); + font-family: var(--font-inter), sans-serif; + font-size: 13px; + padding: 4px 2px; +} + +.resultList { + display: flex; + flex-direction: column; + border: 1px solid var(--separator); + border-radius: 10px; + overflow: hidden; + background: var(--secondary); +} + +.resultRow { + display: grid; + grid-template-columns: 2fr 1fr auto auto; + gap: 16px; + align-items: center; + padding: 14px 18px; + border-bottom: 1px solid var(--separator); + color: var(--primary-text); + font-family: var(--font-inter), sans-serif; + font-size: 14px; + text-decoration: none; + transition: background-color 120ms ease; +} + +.resultRow:last-child { + border-bottom: none; +} + +.resultRow:hover { + background: var(--primary-hover); +} + +.title { + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.id { + color: var(--secondary-text); + font-family: var(--font-courier), monospace; + font-size: 12px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.members { + color: var(--secondary-text); + font-size: 12px; + white-space: nowrap; +} + +.date { + color: var(--secondary-text); + font-size: 12px; + white-space: nowrap; +} + +.empty { + color: var(--secondary-text); + font-family: var(--font-inter), sans-serif; + font-size: 14px; + padding: 24px; + text-align: center; +} diff --git a/components/admin/ProjectSearch.tsx b/components/admin/ProjectSearch.tsx new file mode 100644 index 00000000..7ae94a5d --- /dev/null +++ b/components/admin/ProjectSearch.tsx @@ -0,0 +1,88 @@ +"use client"; + +import { useEffect, useState } from "react"; +import useSWR from "swr"; +import Link from "next/link"; +import { Search } from "lucide-react"; +import styles from "./ProjectSearch.module.css"; + +type ProjectResult = { + id: string; + title: string; + author: string | null; + createdAt: string; + updatedAt: string; + _count: { members: number }; +}; + +type SearchResponse = { + projects: ProjectResult[]; + nextCursor: number | null; +}; + +function formatDate(iso: string) { + return new Date(iso).toLocaleDateString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + }); +} + +export default function ProjectSearch() { + const [query, setQuery] = useState(""); + const [debounced, setDebounced] = useState(""); + + useEffect(() => { + const t = setTimeout(() => setDebounced(query.trim()), 250); + return () => clearTimeout(t); + }, [query]); + + const key = debounced ? `/api/admin/projects?q=${encodeURIComponent(debounced)}` : null; + const { data, isLoading } = useSWR(key); + + return ( +
+
+ + setQuery(e.target.value)} + autoFocus + /> +
+ + {!debounced && ( +

+ Start typing a title (partial, case-insensitive) or paste a full project ID. +

+ )} + + {debounced && isLoading &&

Searching…

} + + {debounced && data && data.projects.length === 0 && ( +
+
No projects match "{debounced}".
+
+ )} + + {data && data.projects.length > 0 && ( +
+ {data.projects.map((p) => ( + + {p.title} + {p.id} + + {p._count.members} member{p._count.members !== 1 ? "s" : ""} + + + Updated {formatDate(p.updatedAt)} + + + ))} +
+ )} +
+ ); +} diff --git a/components/admin/StatsCards.module.css b/components/admin/StatsCards.module.css new file mode 100644 index 00000000..241bc100 --- /dev/null +++ b/components/admin/StatsCards.module.css @@ -0,0 +1,51 @@ +.grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 16px; +} + +.card { + background: var(--secondary); + border: 1px solid var(--separator); + border-radius: 12px; + padding: 20px; + box-shadow: var(--panel-shadow); + display: flex; + flex-direction: column; + gap: 8px; +} + +.label { + color: var(--secondary-text); + font-family: var(--font-inter), sans-serif; + font-size: 12px; + letter-spacing: 0.06em; + text-transform: uppercase; + font-weight: 600; +} + +.value { + color: var(--primary-text); + font-family: var(--font-josefin), sans-serif; + font-size: 32px; + font-weight: 700; + line-height: 1; +} + +.hint { + color: var(--secondary-text); + font-family: var(--font-inter), sans-serif; + font-size: 12px; +} + +.loading, +.error { + color: var(--secondary-text); + font-family: var(--font-inter), sans-serif; + font-size: 14px; + padding: 12px 4px; +} + +.error { + color: var(--error); +} diff --git a/components/admin/StatsCards.tsx b/components/admin/StatsCards.tsx new file mode 100644 index 00000000..921bae90 --- /dev/null +++ b/components/admin/StatsCards.tsx @@ -0,0 +1,41 @@ +"use client"; + +import useSWR from "swr"; +import styles from "./StatsCards.module.css"; + +type Stats = { + userCount: number; + activeProCount: number; + projectCount: number; + transactionsThisMonth: number; +}; + +export default function StatsCards() { + const { data, error, isLoading } = useSWR("/api/admin/stats"); + + if (isLoading) return
Loading stats…
; + if (error || !data) return
Failed to load stats.
; + + const cards = [ + { label: "Users", value: data.userCount, hint: "All-time registrations" }, + { label: "Active Pro", value: data.activeProCount, hint: "isProUntil > now" }, + { label: "Projects", value: data.projectCount, hint: "All projects" }, + { + label: "Transactions (this month)", + value: data.transactionsThisMonth, + hint: "Since the 1st", + }, + ]; + + return ( +
+ {cards.map((c) => ( +
+ {c.label} + {c.value.toLocaleString()} + {c.hint} +
+ ))} +
+ ); +} diff --git a/components/admin/UserDetail.module.css b/components/admin/UserDetail.module.css new file mode 100644 index 00000000..62455ea1 --- /dev/null +++ b/components/admin/UserDetail.module.css @@ -0,0 +1,177 @@ +.container { + display: flex; + flex-direction: column; + gap: 24px; +} + +.backLink { + display: inline-flex; + align-items: center; + gap: 6px; + color: var(--secondary-text); + text-decoration: none; + font-family: var(--font-inter), sans-serif; + font-size: 13px; + margin-bottom: 8px; + width: fit-content; +} + +.backLink:hover { + color: var(--primary-text); +} + +.card { + background: var(--secondary); + border: 1px solid var(--separator); + border-radius: 12px; + padding: 24px; + box-shadow: var(--panel-shadow); +} + +.cardTitle { + color: var(--primary-text); + font-family: var(--font-josefin), sans-serif; + font-size: 18px; + font-weight: 700; + margin-bottom: 16px; +} + +.fields { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 16px 24px; +} + +.field { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; +} + +.fieldLabel { + color: var(--secondary-text); + font-family: var(--font-inter), sans-serif; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.fieldValue { + color: var(--primary-text); + font-family: var(--font-inter), sans-serif; + font-size: 14px; + overflow-wrap: anywhere; +} + +.idValue { + font-family: var(--font-courier), monospace; + font-size: 13px; +} + +.badge { + display: inline-flex; + align-items: center; + padding: 3px 10px; + border-radius: 999px; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + width: fit-content; +} + +.badgePro { + background: color-mix(in srgb, var(--success) 22%, transparent); + color: var(--success); +} + +.badgeAdmin { + background: color-mix(in srgb, var(--warning) 25%, transparent); + color: var(--warning); +} + +.badgeMuted { + background: var(--tertiary); + color: var(--secondary-text); +} + +.badgeDanger { + background: color-mix(in srgb, var(--error) 22%, transparent); + color: var(--error); +} + +.table { + display: flex; + flex-direction: column; + border: 1px solid var(--separator); + border-radius: 8px; + overflow: hidden; +} + +.tableRow { + display: grid; + grid-template-columns: 2fr 1fr 1fr; + gap: 16px; + padding: 12px 16px; + font-family: var(--font-inter), sans-serif; + font-size: 13px; + color: var(--primary-text); + border-bottom: 1px solid var(--separator); + align-items: center; +} + +.tableRow:last-child { + border-bottom: none; +} + +.tableHeader { + background: var(--primary); + color: var(--secondary-text); + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.06em; + font-weight: 600; +} + +.tableRowLink { + text-decoration: none; + transition: background-color 120ms ease; + cursor: pointer; +} + +.tableRowLink:hover { + background: var(--primary-hover); +} + +.tableCellMuted { + color: var(--secondary-text); + font-size: 12px; +} + +.tableCellMono { + font-family: var(--font-courier), monospace; + font-size: 12px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.emptyRow { + padding: 16px; + text-align: center; + color: var(--secondary-text); + font-family: var(--font-inter), sans-serif; + font-size: 13px; +} + +.message { + color: var(--secondary-text); + font-family: var(--font-inter), sans-serif; + font-size: 14px; +} + +.error { + color: var(--error); +} diff --git a/components/admin/UserDetail.tsx b/components/admin/UserDetail.tsx new file mode 100644 index 00000000..4e885669 --- /dev/null +++ b/components/admin/UserDetail.tsx @@ -0,0 +1,242 @@ +"use client"; + +import Link from "next/link"; +import useSWR from "swr"; +import { ArrowLeft } from "lucide-react"; +import { UserRole, SubscriptionProvider, ProjectRole } from "../../src/generated/client/browser"; +import styles from "./UserDetail.module.css"; + +type UserDetailPayload = { + user: { + id: string; + email: string; + createdAt: string; + emailVerified: string | null; + username: string | null; + role: UserRole; + isProUntil: string | null; + isSubscriptionCancelled: boolean; + subscriptionProvider: SubscriptionProvider | null; + }; + projectCount: number; + transactionCount: number; +}; + +type Membership = { + role: ProjectRole; + project: { + id: string; + title: string; + createdAt: string; + updatedAt: string; + }; +}; + +type Transaction = { + id: number; + provider: SubscriptionProvider; + transactionId: string; + createdAt: string; +}; + +function formatDateTime(iso: string | null) { + if (!iso) return "—"; + const d = new Date(iso); + return d.toLocaleString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +} + +function isPro(isProUntil: string | null) { + return !!isProUntil && new Date(isProUntil) > new Date(); +} + +type Props = { userId: string }; + +export default function UserDetail({ userId }: Props) { + const { data, error, isLoading } = useSWR( + `/api/admin/users/${userId}`, + ); + const { data: memberships } = useSWR( + `/api/admin/users/${userId}/projects`, + ); + const { data: transactions } = useSWR( + `/api/admin/users/${userId}/transactions`, + ); + + return ( +
+ + Back to search + + + {isLoading &&

Loading user…

} + {error &&

User not found.

} + + {data && ( + <> +
+

Account

+
+
+ Email + {data.user.email} +
+
+ User ID + + {data.user.id} + +
+
+ Username + + {data.user.username ?? "—"} + +
+
+ Created + + {formatDateTime(data.user.createdAt)} + +
+
+ Email verified + + {data.user.emailVerified + ? formatDateTime(data.user.emailVerified) + : "No"} + +
+
+ Role + + {data.user.role} + +
+
+
+ +
+

Subscription

+
+
+ Status + + {isPro(data.user.isProUntil) ? "Pro" : "Free"} + +
+
+ Pro until + + {formatDateTime(data.user.isProUntil)} + +
+
+ Provider + + {data.user.subscriptionProvider ?? "—"} + +
+
+ Cancelled + + {data.user.isSubscriptionCancelled ? "Yes" : "No"} + +
+
+
+ +
+

+ Projects ({data.projectCount}) +

+
+
+ Title + Role + Updated +
+ {!memberships && ( +
Loading…
+ )} + {memberships && memberships.length === 0 && ( +
+ This user has no projects. +
+ )} + {memberships?.map((m) => ( + + {m.project.title} + {m.role} + + {formatDateTime(m.project.updatedAt)} + + + ))} +
+
+ +
+

+ Transactions ({data.transactionCount}) +

+
+
+ Transaction ID + Provider + Created +
+ {!transactions && ( +
Loading…
+ )} + {transactions && transactions.length === 0 && ( +
No transactions.
+ )} + {transactions?.map((t) => ( +
+ + {t.transactionId} + + {t.provider} + + {formatDateTime(t.createdAt)} + +
+ ))} +
+
+ + )} +
+ ); +} diff --git a/components/admin/UserSearch.module.css b/components/admin/UserSearch.module.css new file mode 100644 index 00000000..69093ed7 --- /dev/null +++ b/components/admin/UserSearch.module.css @@ -0,0 +1,128 @@ +.container { + display: flex; + flex-direction: column; + gap: 20px; +} + +.searchBar { + display: flex; + align-items: center; + gap: 10px; + background: var(--secondary); + border: 1px solid var(--input-outline); + border-radius: 10px; + padding: 10px 14px; + box-shadow: var(--panel-shadow); +} + +.searchBar:focus-within { + border-color: var(--primary-text); +} + +.searchInput { + flex: 1; + background: transparent; + border: none; + outline: none; + color: var(--primary-text); + font-family: var(--font-inter), sans-serif; + font-size: 14px; +} + +.hint { + color: var(--secondary-text); + font-family: var(--font-inter), sans-serif; + font-size: 13px; + padding: 4px 2px; +} + +.resultList { + display: flex; + flex-direction: column; + border: 1px solid var(--separator); + border-radius: 10px; + overflow: hidden; + background: var(--secondary); +} + +.resultRow { + display: grid; + grid-template-columns: 1.5fr 1fr auto auto; + gap: 16px; + align-items: center; + padding: 14px 18px; + border: none; + border-bottom: 1px solid var(--separator); + background: transparent; + color: var(--primary-text); + font-family: var(--font-inter), sans-serif; + font-size: 14px; + text-align: left; + text-decoration: none; + cursor: pointer; + transition: background-color 120ms ease; +} + +.resultRow:last-child { + border-bottom: none; +} + +.resultRow:hover { + background: var(--primary-hover); +} + +.email { + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.id { + color: var(--secondary-text); + font-family: var(--font-courier), monospace; + font-size: 12px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.badge { + display: inline-flex; + align-items: center; + padding: 3px 10px; + border-radius: 999px; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.badgePro { + background: color-mix(in srgb, var(--success) 22%, transparent); + color: var(--success); +} + +.badgeAdmin { + background: color-mix(in srgb, var(--warning) 25%, transparent); + color: var(--warning); +} + +.badgeMuted { + background: var(--tertiary); + color: var(--secondary-text); +} + +.date { + color: var(--secondary-text); + font-size: 12px; + white-space: nowrap; +} + +.empty { + color: var(--secondary-text); + font-family: var(--font-inter), sans-serif; + font-size: 14px; + padding: 24px; + text-align: center; +} diff --git a/components/admin/UserSearch.tsx b/components/admin/UserSearch.tsx new file mode 100644 index 00000000..ad5c8499 --- /dev/null +++ b/components/admin/UserSearch.tsx @@ -0,0 +1,100 @@ +"use client"; + +import { useEffect, useState } from "react"; +import useSWR from "swr"; +import Link from "next/link"; +import { Search } from "lucide-react"; +import { UserRole, SubscriptionProvider } from "../../src/generated/client/browser"; +import styles from "./UserSearch.module.css"; + +type SearchResult = { + id: string; + email: string; + createdAt: string; + role: UserRole; + isProUntil: string | null; + subscriptionProvider: SubscriptionProvider | null; +}; + +type SearchResponse = { + users: SearchResult[]; + nextCursor: number | null; +}; + +function formatDate(iso: string) { + const d = new Date(iso); + return d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" }); +} + +function isPro(isProUntil: string | null) { + return !!isProUntil && new Date(isProUntil) > new Date(); +} + +export default function UserSearch() { + const [query, setQuery] = useState(""); + const [debounced, setDebounced] = useState(""); + + useEffect(() => { + const t = setTimeout(() => setDebounced(query.trim()), 250); + return () => clearTimeout(t); + }, [query]); + + const key = debounced ? `/api/admin/users?q=${encodeURIComponent(debounced)}` : null; + const { data, isLoading } = useSWR(key); + + return ( +
+
+ + setQuery(e.target.value)} + autoFocus + /> +
+ + {!debounced && ( +

+ Start typing an email (partial, case-insensitive) or paste a full user ID. +

+ )} + + {debounced && isLoading &&

Searching…

} + + {debounced && data && data.users.length === 0 && ( +
+
No users match "{debounced}".
+
+ )} + + {data && data.users.length > 0 && ( +
+ {data.users.map((u) => ( + + {u.email} + {u.id} + + {u.role === UserRole.ADMIN + ? "Admin" + : isPro(u.isProUntil) + ? "Pro" + : "Free"} + + {formatDate(u.createdAt)} + + ))} +
+ )} +
+ ); +} diff --git a/components/dashboard/project/CollaboratorsSettings.tsx b/components/dashboard/project/CollaboratorsSettings.tsx index 05c3d6e7..7d98ce24 100644 --- a/components/dashboard/project/CollaboratorsSettings.tsx +++ b/components/dashboard/project/CollaboratorsSettings.tsx @@ -4,7 +4,7 @@ import { useContext, useMemo, useState } from "react"; import { useTranslations } from "next-intl"; import { useCookieUser, useIsPro, useProjectCollaborators, useProjectInvites, useProjectMembership } from "@src/lib/utils/hooks"; import { CookieUser } from "@src/lib/utils/types"; -import { ProjectRole } from "@prisma/client"; +import { ProjectRole } from "../../../src/generated/client/browser"; import { Collaborator, ProjectInvite, ProjectMembershipPayload } from "@src/server/repository/project-repository"; import { Info, Lock } from "lucide-react"; diff --git a/docker-compose.yml b/docker-compose.yml index 9a4ae7f7..ab348deb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -114,6 +114,71 @@ services: timeout: 5s retries: 5 + prometheus: + image: prom/prometheus:latest + container_name: scriptio-prometheus + profiles: ["prod"] + restart: unless-stopped + command: + - --config.file=/etc/prometheus/prometheus.yml + - --storage.tsdb.retention.time=30d + volumes: + - ./monitoring/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro + - prometheus_data:/prometheus + configs: + - source: metrics_bearer_token + target: /etc/prometheus/bearer-token + mode: 0400 + networks: + - internal + depends_on: + - app-prod + + grafana: + image: grafana/grafana:latest + container_name: scriptio-grafana + profiles: ["prod"] + restart: unless-stopped + environment: + - GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_ADMIN_PASSWORD} + - GF_SERVER_ROOT_URL=https://monitoring.scriptio.app + - GF_USERS_ALLOW_SIGN_UP=false + - GF_SMTP_ENABLED=true + - GF_SMTP_HOST=${SMTP_HOST}:587 + - GF_SMTP_USER=${SMTP_USER} + - GF_SMTP_PASSWORD=${SMTP_SECRET} + - GF_SMTP_FROM_ADDRESS=noreply@scriptio.app + - GF_SMTP_FROM_NAME=Scriptio Monitoring + - APP_MEMORY_LIMIT_BYTES=${APP_MEMORY_LIMIT_BYTES} + - DB_SIZE_LIMIT_BYTES=${DB_SIZE_LIMIT_BYTES} + volumes: + - ./monitoring/grafana/provisioning:/etc/grafana/provisioning:ro + - ./monitoring/grafana/dashboards:/etc/grafana/dashboards:ro + - grafana_data:/var/lib/grafana + networks: + - webproxy + - internal + depends_on: + - prometheus + labels: + - traefik.enable=true + - traefik.docker.network=webproxy + - traefik.http.routers.scriptio-monitoring.rule=Host(`monitoring.scriptio.app`) + - traefik.http.routers.scriptio-monitoring.entrypoints=secure + - traefik.http.routers.scriptio-monitoring.tls=true + - traefik.http.routers.scriptio-monitoring.tls.certresolver=letsencrypt + - traefik.http.routers.scriptio-monitoring.middlewares=monitoring-auth + - traefik.http.middlewares.monitoring-auth.basicauth.users=${MONITORING_AUTH_USERS} + - traefik.http.services.scriptio-monitoring.loadbalancer.server.port=3000 + +configs: + metrics_bearer_token: + content: ${METRICS_BEARER_TOKEN} + +volumes: + prometheus_data: + grafana_data: + networks: webproxy: external: true diff --git a/monitoring/grafana/dashboards/scriptio-overview.json b/monitoring/grafana/dashboards/scriptio-overview.json new file mode 100644 index 00000000..351ccb6f --- /dev/null +++ b/monitoring/grafana/dashboards/scriptio-overview.json @@ -0,0 +1,102 @@ +{ + "uid": "scriptio-overview", + "title": "Scriptio Overview", + "timezone": "browser", + "schemaVersion": 39, + "refresh": "30s", + "time": { "from": "now-6h", "to": "now" }, + "templating": { + "list": [ + { + "name": "memory_limit_bytes", + "type": "constant", + "label": "Memory limit (bytes)", + "query": "${APP_MEMORY_LIMIT_BYTES}", + "current": { "value": "${APP_MEMORY_LIMIT_BYTES}", "text": "${APP_MEMORY_LIMIT_BYTES}" }, + "hide": 2 + }, + { + "name": "db_size_limit_bytes", + "type": "constant", + "label": "DB size limit (bytes)", + "query": "${DB_SIZE_LIMIT_BYTES}", + "current": { "value": "${DB_SIZE_LIMIT_BYTES}", "text": "${DB_SIZE_LIMIT_BYTES}" }, + "hide": 2 + } + ] + }, + "panels": [ + { + "id": 1, + "type": "timeseries", + "title": "Database size", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 0 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "fieldConfig": { + "defaults": { "unit": "bytes" }, + "overrides": [] + }, + "targets": [ + { + "refId": "A", + "expr": "scriptio_db_size_bytes", + "legendFormat": "DB size" + } + ] + }, + { + "id": 2, + "type": "timeseries", + "title": "HTTP error rate (4xx + 5xx)", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "fieldConfig": { + "defaults": { "unit": "percentunit", "min": 0, "max": 1 }, + "overrides": [] + }, + "targets": [ + { + "refId": "A", + "expr": "sum(rate(http_requests_total{status=~\"4..|5..\"}[5m])) / sum(rate(http_requests_total[5m]))", + "legendFormat": "error rate" + } + ] + }, + { + "id": 3, + "type": "timeseries", + "title": "CPU usage (% of one core)", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 8 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "fieldConfig": { + "defaults": { "unit": "percent", "min": 0 }, + "overrides": [] + }, + "targets": [ + { + "refId": "A", + "expr": "rate(process_cpu_seconds_total[2m]) * 100", + "legendFormat": "cpu %" + } + ] + }, + { + "id": 4, + "type": "timeseries", + "title": "Memory usage (% of APP_MEMORY_LIMIT_BYTES)", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 8 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "fieldConfig": { + "defaults": { "unit": "percent", "min": 0, "max": 100 }, + "overrides": [] + }, + "targets": [ + { + "refId": "A", + "expr": "process_resident_memory_bytes / $memory_limit_bytes * 100", + "legendFormat": "memory %" + } + ] + } + ] +} diff --git a/monitoring/grafana/provisioning/alerting/contact-points.yml b/monitoring/grafana/provisioning/alerting/contact-points.yml new file mode 100644 index 00000000..f2f16f36 --- /dev/null +++ b/monitoring/grafana/provisioning/alerting/contact-points.yml @@ -0,0 +1,11 @@ +apiVersion: 1 + +contactPoints: + - orgId: 1 + name: scriptio-email + receivers: + - uid: scriptio-email + type: email + settings: + addresses: hugo.bois@hotmail.fr + singleEmail: true diff --git a/monitoring/grafana/provisioning/alerting/notification-policies.yml b/monitoring/grafana/provisioning/alerting/notification-policies.yml new file mode 100644 index 00000000..bcb35290 --- /dev/null +++ b/monitoring/grafana/provisioning/alerting/notification-policies.yml @@ -0,0 +1,9 @@ +apiVersion: 1 + +policies: + - orgId: 1 + receiver: scriptio-email + group_by: [alertname] + group_wait: 30s + group_interval: 5m + repeat_interval: 6h diff --git a/monitoring/grafana/provisioning/alerting/rules.yml b/monitoring/grafana/provisioning/alerting/rules.yml new file mode 100644 index 00000000..c78106dc --- /dev/null +++ b/monitoring/grafana/provisioning/alerting/rules.yml @@ -0,0 +1,73 @@ +apiVersion: 1 + +groups: + - orgId: 1 + name: scriptio-app + folder: scriptio + interval: 1m + rules: + - uid: scriptio-http-error-rate + title: HighHttpErrorRate + condition: C + data: + - refId: A + relativeTimeRange: + from: 600 + to: 0 + datasourceUid: prometheus + model: + refId: A + expr: sum(rate(http_requests_total{status=~"4..|5.."}[5m])) / sum(rate(http_requests_total[5m])) + instant: true + - refId: C + relativeTimeRange: + from: 600 + to: 0 + datasourceUid: __expr__ + model: + refId: C + type: threshold + expression: A + conditions: + - evaluator: + type: gt + params: [0.05] + for: 5m + noDataState: OK + execErrState: Error + annotations: + summary: HTTP error rate above 5% + description: "{{ $value | printf \"%.2f\" }} of requests in the last 5 minutes returned a 4xx/5xx status." + + - uid: scriptio-db-almost-full + title: DbAlmostFull + condition: C + data: + - refId: A + relativeTimeRange: + from: 600 + to: 0 + datasourceUid: prometheus + model: + refId: A + expr: scriptio_db_size_bytes / ${DB_SIZE_LIMIT_BYTES} + instant: true + - refId: C + relativeTimeRange: + from: 600 + to: 0 + datasourceUid: __expr__ + model: + refId: C + type: threshold + expression: A + conditions: + - evaluator: + type: gt + params: [0.85] + for: 10m + noDataState: OK + execErrState: Error + annotations: + summary: Database is approaching its capacity ceiling + description: "DB is at {{ $value | humanizePercentage }} of the configured DB_SIZE_LIMIT_BYTES." diff --git a/monitoring/grafana/provisioning/dashboards/dashboards.yml b/monitoring/grafana/provisioning/dashboards/dashboards.yml new file mode 100644 index 00000000..cba8dccc --- /dev/null +++ b/monitoring/grafana/provisioning/dashboards/dashboards.yml @@ -0,0 +1,13 @@ +apiVersion: 1 + +providers: + - name: scriptio + orgId: 1 + folder: "" + type: file + disableDeletion: true + updateIntervalSeconds: 30 + allowUiUpdates: false + options: + path: /etc/grafana/dashboards + foldersFromFilesStructure: false diff --git a/monitoring/grafana/provisioning/datasources/prometheus.yml b/monitoring/grafana/provisioning/datasources/prometheus.yml new file mode 100644 index 00000000..8967eb69 --- /dev/null +++ b/monitoring/grafana/provisioning/datasources/prometheus.yml @@ -0,0 +1,10 @@ +apiVersion: 1 + +datasources: + - name: Prometheus + uid: prometheus + type: prometheus + access: proxy + url: http://prometheus:9090 + isDefault: true + editable: false diff --git a/monitoring/prometheus/prometheus.yml b/monitoring/prometheus/prometheus.yml new file mode 100644 index 00000000..ea137064 --- /dev/null +++ b/monitoring/prometheus/prometheus.yml @@ -0,0 +1,13 @@ +global: + scrape_interval: 15s + evaluation_interval: 15s + +scrape_configs: + - job_name: app-prod + metrics_path: /api/metrics + scheme: http + authorization: + type: Bearer + credentials_file: /etc/prometheus/bearer-token + static_configs: + - targets: ["app-prod:3000"] diff --git a/next.config.ts b/next.config.ts index 7fac6fcd..6f17229b 100644 --- a/next.config.ts +++ b/next.config.ts @@ -22,4 +22,4 @@ const config: NextConfig = { }, }; -module.exports = config; +export default config; diff --git a/package-lock.json b/package-lock.json index 0a60567c..8ff438d4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,13 +14,13 @@ "@aws-sdk/s3-request-presigner": "^3.952.0", "@choochmeque/tauri-plugin-iap-api": "^0.8.2", "@formkit/auto-animate": "^0.7.0", - "@prisma/client": "^6.19.0", + "@prisma/adapter-pg": "^7.8.0", + "@prisma/client": "^7.7.0", "@svgr/core": "^8.1.0", "@tauri-apps/api": "2.10.1", "@tauri-apps/plugin-dialog": "2.6.0", "@tauri-apps/plugin-fs": "2.4.5", "@tauri-apps/plugin-opener": "2.5.3", - "@tauri-apps/plugin-sql": "2.3.2", "@tauri-apps/plugin-store": "^2.4.2", "@tiptap/extension-bold": "^3.13.0", "@tiptap/extension-collaboration": "^3.13.0", @@ -52,6 +52,8 @@ "next-themes": "^0.2.0", "nodemailer": "^7.0.12", "pdfmake": "^0.2.9", + "pg": "^8.20.0", + "prom-client": "^15.1.3", "react": "^19.2.4", "react-chartjs-2": "^5.2.0", "react-dom": "^19.2.4", @@ -77,6 +79,7 @@ "@types/node": "20.11.0", "@types/nodemailer": "^6.4.14", "@types/pdfmake": "^0.2.12", + "@types/pg": "^8.20.0", "@types/react": "^18.3.27", "@types/react-dom": "^18.3.7", "@types/uuid": "^10.0.0", @@ -91,9 +94,9 @@ "eslint-plugin-react-hooks": "^7.0.1", "patch-package": "^8.0.1", "playwright": "^1.58.2", - "prisma": "^6.19.0", + "prisma": "^7.7.0", "tsx": "^4.21.0", - "typescript": "^5.1.3", + "typescript": "^5.7.3", "typescript-plugin-css-modules": "^5.2.0", "vite": "^6.4.1", "vitest": "^3.2.4", @@ -3056,6 +3059,36 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@electric-sql/pglite": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.4.1.tgz", + "integrity": "sha512-mZ9NzzUSYPOCnxHH1oAHPRzoMFJHY472raDKwXl/+6oPbpdJ7g8LsCN4FSaIIfkiCKHhb3iF/Zqo3NYxaIhU7Q==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@electric-sql/pglite-socket": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@electric-sql/pglite-socket/-/pglite-socket-0.1.1.tgz", + "integrity": "sha512-p2hoXw3Z3LQHwTeikdZNsFBOvXGqKY2hk51BBw+8NKND8eoH+8LFOtW9Z8CQKmTJ2qqGYu82ipqiyFZOTTXNfw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "pglite-server": "dist/scripts/server.js" + }, + "peerDependencies": { + "@electric-sql/pglite": "0.4.1" + } + }, + "node_modules/@electric-sql/pglite-tools": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@electric-sql/pglite-tools/-/pglite-tools-0.3.1.tgz", + "integrity": "sha512-C+T3oivmy9bpQvSxVqXA1UDY8cB9Eb9vZHL9zxWwEUfDixbXv4G3r2LjoTdR33LD8aomR3O9ZXEO3XEwr/cUCA==", + "devOptional": true, + "license": "Apache-2.0", + "peerDependencies": { + "@electric-sql/pglite": "0.4.1" + } + }, "node_modules/@emnapi/core": { "version": "1.7.1", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.7.1.tgz", @@ -3904,6 +3937,19 @@ "@hapi/hoek": "^11.0.2" } }, + "node_modules/@hono/node-server": { + "version": "1.19.11", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz", + "integrity": "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -5320,6 +5366,15 @@ "node": ">=12.4.0" } }, + "node_modules/@opentelemetry/api": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", + "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/@panva/hkdf": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz", @@ -5691,18 +5746,32 @@ "dev": true, "license": "MIT" }, + "node_modules/@prisma/adapter-pg": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/adapter-pg/-/adapter-pg-7.8.0.tgz", + "integrity": "sha512-ygb3UkerK3v8MDpXVgCISdRNDozpxh6+JVJgiIGbSr5KBgz10LLf5ejUskPGoXlsIjxsOu6nuy1JVQr2EKGSlg==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/driver-adapter-utils": "7.8.0", + "@types/pg": "^8.16.0", + "pg": "^8.16.3", + "postgres-array": "3.0.4" + } + }, "node_modules/@prisma/client": { - "version": "6.19.1", - "resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.19.1.tgz", - "integrity": "sha512-4SXj4Oo6HyQkLUWT8Ke5R0PTAfVOKip5Roo+6+b2EDTkFg5be0FnBWiuRJc0BC0sRQIWGMLKW1XguhVfW/z3/A==", - "hasInstallScript": true, + "version": "7.7.0", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-7.7.0.tgz", + "integrity": "sha512-5Ar4OsZpJ54s21sy5oDNNW9gQtd4NuxCaiM7+JDTOU07D6VvlpLjYzAVCMB1+JzokN+08dAVomlx+b7bhJd3ww==", "license": "Apache-2.0", + "dependencies": { + "@prisma/client-runtime-utils": "7.7.0" + }, "engines": { - "node": ">=18.18" + "node": "^20.19 || ^22.12 || >=24.0" }, "peerDependencies": { "prisma": "*", - "typescript": ">=5.1.0" + "typescript": ">=5.4.0" }, "peerDependenciesMeta": { "prisma": { @@ -5713,67 +5782,363 @@ } } }, + "node_modules/@prisma/client-runtime-utils": { + "version": "7.7.0", + "resolved": "https://registry.npmjs.org/@prisma/client-runtime-utils/-/client-runtime-utils-7.7.0.tgz", + "integrity": "sha512-BLyd0UpFYOtyJFTHm7jS9vesHW7P83abibodQMiIofqjBKzDHQ1VAsQkdfvXyYDkPlONPfOTz7/rv3x/+CQqvQ==", + "license": "Apache-2.0" + }, "node_modules/@prisma/config": { - "version": "6.19.1", - "resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.19.1.tgz", - "integrity": "sha512-bUL/aYkGXLwxVGhJmQMtslLT7KPEfUqmRa919fKI4wQFX4bIFUKiY8Jmio/2waAjjPYrtuDHa7EsNCnJTXxiOw==", + "version": "7.7.0", + "resolved": "https://registry.npmjs.org/@prisma/config/-/config-7.7.0.tgz", + "integrity": "sha512-hmPI3tKLO2aP0Y5vugbjcnA9qqlfJndiT6ds4tw28U5hNHLWg+mHJEWAhjsSPgxjtmxhJ/EDIeIlyh+3Us0OPg==", "devOptional": true, "license": "Apache-2.0", "dependencies": { "c12": "3.1.0", "deepmerge-ts": "7.1.5", - "effect": "3.18.4", + "effect": "3.20.0", "empathic": "2.0.0" } }, "node_modules/@prisma/debug": { - "version": "6.19.1", - "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.19.1.tgz", - "integrity": "sha512-h1JImhlAd/s5nhY/e9qkAzausWldbeT+e4nZF7A4zjDYBF4BZmKDt4y0jK7EZapqOm1kW7V0e9agV/iFDy3fWw==", - "devOptional": true, + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.8.0.tgz", + "integrity": "sha512-p+QZReysDUqXC+mk17q9a+Y/qzh4c2KYliDK30buYUyfrGeTGSyfmc0AIrJRhZJrLHhRiJa9Au/J72h3C+szvA==", "license": "Apache-2.0" }, + "node_modules/@prisma/dev": { + "version": "0.24.3", + "resolved": "https://registry.npmjs.org/@prisma/dev/-/dev-0.24.3.tgz", + "integrity": "sha512-ffHlQuKXZiaDt9Go0OnCTdJZrHxK0k7omJKNV86/VjpsXu5EIHZLK0T7JSWgvNlJwh56kW9JFu9v0qJciFzepg==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "@electric-sql/pglite": "0.4.1", + "@electric-sql/pglite-socket": "0.1.1", + "@electric-sql/pglite-tools": "0.3.1", + "@hono/node-server": "1.19.11", + "@prisma/get-platform": "7.2.0", + "@prisma/query-plan-executor": "7.2.0", + "@prisma/streams-local": "0.1.2", + "foreground-child": "3.3.1", + "get-port-please": "3.2.0", + "hono": "^4.12.8", + "http-status-codes": "2.3.0", + "pathe": "2.0.3", + "proper-lockfile": "4.1.2", + "remeda": "2.33.4", + "std-env": "3.10.0", + "valibot": "1.2.0", + "zeptomatch": "2.1.0" + } + }, + "node_modules/@prisma/driver-adapter-utils": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/driver-adapter-utils/-/driver-adapter-utils-7.8.0.tgz", + "integrity": "sha512-/Q13o0ZT0rjc1Xk0Q9KhZYwuq2EW/vSbWUBKfgEKkaCuB/Sg6bqnjmTZqC5cD4d6y1vfFAEwBRzfzoSMIVJ55A==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.8.0" + } + }, "node_modules/@prisma/engines": { - "version": "6.19.1", - "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.19.1.tgz", - "integrity": "sha512-xy95dNJ7DiPf9IJ3oaVfX785nbFl7oNDzclUF+DIiJw6WdWCvPl0LPU0YqQLsrwv8N64uOQkH391ujo3wSo+Nw==", + "version": "7.7.0", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-7.7.0.tgz", + "integrity": "sha512-7fmcbT7HHXBq/b+3h/dO1JI3fd8l8q7erf7xP7pRprh58hmSSnG8mg9K3yjW3h9WaHWUwngVFpSxxxivaitQ2w==", "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "@prisma/debug": "6.19.1", - "@prisma/engines-version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", - "@prisma/fetch-engine": "6.19.1", - "@prisma/get-platform": "6.19.1" + "@prisma/debug": "7.7.0", + "@prisma/engines-version": "7.6.0-1.75cbdc1eb7150937890ad5465d861175c6624711", + "@prisma/fetch-engine": "7.7.0", + "@prisma/get-platform": "7.7.0" } }, "node_modules/@prisma/engines-version": { - "version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", - "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7.tgz", - "integrity": "sha512-03bgb1VD5gvuumNf+7fVGBzfpJPjmqV423l/WxsWk2cNQ42JD0/SsFBPhN6z8iAvdHs07/7ei77SKu7aZfq8bA==", + "version": "7.6.0-1.75cbdc1eb7150937890ad5465d861175c6624711", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.6.0-1.75cbdc1eb7150937890ad5465d861175c6624711.tgz", + "integrity": "sha512-r51DLcJ8bDRSrBEJF3J4cinoWyGA7rfP2mG6lD90VqIbGNOkbfcLcXalSVjq5Y6brQS3vcjrq4GbyUb1Cb7vkw==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/engines/node_modules/@prisma/debug": { + "version": "7.7.0", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.7.0.tgz", + "integrity": "sha512-12J62XdqCmpiwJHhHdQxZeY3ckVCWIFmcJP8hg5dPTceeiQ0wiojXGFYTluKqFQfu46fRLgb/rLALZMAx3+dTA==", "devOptional": true, "license": "Apache-2.0" }, + "node_modules/@prisma/engines/node_modules/@prisma/get-platform": { + "version": "7.7.0", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.7.0.tgz", + "integrity": "sha512-MEUNzvKxvYnJ7kgvd6oNRnMmmiGNS9TYLB2weMeIXplnHdL/UWEGnvavYGnN7KLJ2n0iI4dDAyzSkHI3c7AscQ==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.7.0" + } + }, "node_modules/@prisma/fetch-engine": { - "version": "6.19.1", - "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.19.1.tgz", - "integrity": "sha512-mmgcotdaq4VtAHO6keov3db+hqlBzQS6X7tR7dFCbvXjLVTxBYdSJFRWz+dq7F9p6dvWyy1X0v8BlfRixyQK6g==", + "version": "7.7.0", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-7.7.0.tgz", + "integrity": "sha512-TfyzveBQoK4xALzsTpVhB/0KG1N8zOK0ap+RnBMkzGUu3f98fnQ4QtXa2wlKPhsO2X8a3N5ugFQgcKNoHGmDfw==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.7.0", + "@prisma/engines-version": "7.6.0-1.75cbdc1eb7150937890ad5465d861175c6624711", + "@prisma/get-platform": "7.7.0" + } + }, + "node_modules/@prisma/fetch-engine/node_modules/@prisma/debug": { + "version": "7.7.0", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.7.0.tgz", + "integrity": "sha512-12J62XdqCmpiwJHhHdQxZeY3ckVCWIFmcJP8hg5dPTceeiQ0wiojXGFYTluKqFQfu46fRLgb/rLALZMAx3+dTA==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/fetch-engine/node_modules/@prisma/get-platform": { + "version": "7.7.0", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.7.0.tgz", + "integrity": "sha512-MEUNzvKxvYnJ7kgvd6oNRnMmmiGNS9TYLB2weMeIXplnHdL/UWEGnvavYGnN7KLJ2n0iI4dDAyzSkHI3c7AscQ==", "devOptional": true, "license": "Apache-2.0", "dependencies": { - "@prisma/debug": "6.19.1", - "@prisma/engines-version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", - "@prisma/get-platform": "6.19.1" + "@prisma/debug": "7.7.0" } }, "node_modules/@prisma/get-platform": { - "version": "6.19.1", - "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.19.1.tgz", - "integrity": "sha512-zsg44QUiQAnFUyh6Fbt7c9HjMXHwFTqtrgcX7DAZmRgnkPyYT7Sh8Mn8D5PuuDYNtMOYcpLGg576MLfIORsBYw==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.2.0.tgz", + "integrity": "sha512-k1V0l0Td1732EHpAfi2eySTezyllok9dXb6UQanajkJQzPUGi3vO2z7jdkz67SypFTdmbnyGYxvEvYZdZsMAVA==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.2.0" + } + }, + "node_modules/@prisma/get-platform/node_modules/@prisma/debug": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.2.0.tgz", + "integrity": "sha512-YSGTiSlBAVJPzX4ONZmMotL+ozJwQjRmZweQNIq/ER0tQJKJynNkRB3kyvt37eOfsbMCXk3gnLF6J9OJ4QWftw==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/query-plan-executor": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@prisma/query-plan-executor/-/query-plan-executor-7.2.0.tgz", + "integrity": "sha512-EOZmNzcV8uJ0mae3DhTsiHgoNCuu1J9mULQpGCh62zN3PxPTd+qI9tJvk5jOst8WHKQNwJWR3b39t0XvfBB0WQ==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/streams-local": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@prisma/streams-local/-/streams-local-0.1.2.tgz", + "integrity": "sha512-l49yTxKKF2odFxaAXTmwmkBKL3+bVQ1tFOooGifu4xkdb9NMNLxHj27XAhTylWZod8I+ISGM5erU1xcl/oBCtg==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "ajv": "^8.12.0", + "better-result": "^2.7.0", + "env-paths": "^3.0.0", + "proper-lockfile": "^4.1.2" + }, + "engines": { + "bun": ">=1.3.6", + "node": ">=22.0.0" + } + }, + "node_modules/@prisma/streams-local/node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@prisma/streams-local/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@prisma/studio-core": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@prisma/studio-core/-/studio-core-0.27.3.tgz", + "integrity": "sha512-AADjNFPdsrglxHQVTmHFqv6DuKQZ5WY4p5/gVFY017twvNrSwpLJ9lqUbYYxEu2W7nbvVxTZA8deJ8LseNALsw==", "devOptional": true, "license": "Apache-2.0", "dependencies": { - "@prisma/debug": "6.19.1" + "@radix-ui/react-toggle": "1.1.10", + "chart.js": "4.5.1" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24.0", + "pnpm": "8" + }, + "peerDependencies": { + "@types/react": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + } + }, + "node_modules/@prisma/studio-core/node_modules/@radix-ui/react-toggle": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.10.tgz", + "integrity": "sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@prisma/studio-core/node_modules/@radix-ui/react-toggle/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "devOptional": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", + "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "devOptional": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, "node_modules/@remirror/core-constants": { @@ -7604,15 +7969,6 @@ "@tauri-apps/api": "^2.8.0" } }, - "node_modules/@tauri-apps/plugin-sql": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-sql/-/plugin-sql-2.3.2.tgz", - "integrity": "sha512-4VDXhcKXVpyh5KKpnTGAn6q2DikPHH+TXGh9ZDQzULmG/JEz1RDvzQStgBJKddiukRbYEZ8CGIA2kskx+T+PpA==", - "license": "MIT OR Apache-2.0", - "dependencies": { - "@tauri-apps/api": "^2.10.1" - } - }, "node_modules/@tauri-apps/plugin-store": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-store/-/plugin-store-2.4.2.tgz", @@ -8060,7 +8416,6 @@ "version": "20.11.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.0.tgz", "integrity": "sha512-o9bjXmDNcF7GbM4CNQpmi+TutCgap/K3w1JyKgxAjqx41zp9qlIAVFi0IhCNsJcXolEqLWhbFbEeL0PvYm4pcQ==", - "devOptional": true, "license": "MIT", "dependencies": { "undici-types": "~5.26.4" @@ -8104,6 +8459,17 @@ "@types/pdfkit": "*" } }, + "node_modules/@types/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, "node_modules/@types/postcss-modules-local-by-default": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/postcss-modules-local-by-default/-/postcss-modules-local-by-default-4.0.2.tgz", @@ -9204,6 +9570,16 @@ "node": ">=6.0.0" } }, + "node_modules/aws-ssl-profiles": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", + "integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/axe-core": { "version": "4.11.0", "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.0.tgz", @@ -9309,6 +9685,19 @@ "baseline-browser-mapping": "dist/cli.js" } }, + "node_modules/better-result": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/better-result/-/better-result-2.8.2.tgz", + "integrity": "sha512-YOf0VSj5nUPI27doTtXF+BBnsiRq3qY7avHqfIWnppxTLGyvkLq1QV2RTxkwoZwJ60ywLfZ0raFF4J/G886i7A==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/bintrees": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bintrees/-/bintrees-1.0.2.tgz", + "integrity": "sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw==", + "license": "MIT" + }, "node_modules/blake3-wasm": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz", @@ -9785,9 +10174,9 @@ "license": "MIT" }, "node_modules/confbox": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.2.tgz", - "integrity": "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==", + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", + "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", "devOptional": true, "license": "MIT" }, @@ -9902,7 +10291,7 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -10229,9 +10618,9 @@ } }, "node_modules/defu": { - "version": "6.1.4", - "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz", - "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==", + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", + "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", "devOptional": true, "license": "MIT" }, @@ -10244,6 +10633,16 @@ "node": ">=0.4.0" } }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "devOptional": true, + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -10399,9 +10798,9 @@ } }, "node_modules/effect": { - "version": "3.18.4", - "resolved": "https://registry.npmjs.org/effect/-/effect-3.18.4.tgz", - "integrity": "sha512-b1LXQJLe9D11wfnOKAk3PKxuqYshQ0Heez+y5pnkd3jLj1yx9QhM72zZ9uUrOQyNvrs2GZZd/3maL0ZV18YuDA==", + "version": "3.20.0", + "resolved": "https://registry.npmjs.org/effect/-/effect-3.20.0.tgz", + "integrity": "sha512-qMLfDJscrNG8p/aw+IkT9W7fgj50Z4wG5bLBy0Txsxz8iUHjDIkOgO3SV0WZfnQbNG2VJYb0b+rDLMrhM4+Krw==", "devOptional": true, "license": "MIT", "dependencies": { @@ -10477,6 +10876,19 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/env-paths": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", + "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/errno": { "version": "0.1.8", "resolved": "https://registry.npmjs.org/errno/-/errno-0.1.8.tgz", @@ -11414,7 +11826,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/fast-equals": { @@ -11487,6 +11899,23 @@ "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==", "license": "(MIT AND Zlib)" }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "devOptional": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fast-xml-parser": { "version": "5.3.5", "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.3.5.tgz", @@ -11660,11 +12089,28 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", - "license": "MIT", + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", @@ -11753,6 +12199,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/generate-function": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", + "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "is-property": "^1.0.2" + } + }, "node_modules/generator-function": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", @@ -11796,6 +12252,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-port-please": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/get-port-please/-/get-port-please-3.2.0.tgz", + "integrity": "sha512-I9QVvBw5U/hw3RmWpYKRumUeaDgxTPd401x364rLmWBJcOQ753eov1eTgzDqRG9bqFIfDc7gfzcQEWrUri3o1A==", + "devOptional": true, + "license": "MIT" + }, "node_modules/get-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", @@ -11996,9 +12459,23 @@ "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, + "devOptional": true, "license": "ISC" }, + "node_modules/grammex": { + "version": "3.1.12", + "resolved": "https://registry.npmjs.org/grammex/-/grammex-3.1.12.tgz", + "integrity": "sha512-6ufJOsSA7LcQehIJNCO7HIBykfM7DXQual0Ny780/DEcJIpBlHRvcqEBWGPYd7hrXL2GJ3oJI1MIhaXjWmLQOQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/graphmatch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/graphmatch/-/graphmatch-1.1.1.tgz", + "integrity": "sha512-5ykVn/EXM1hF0XCaWh05VbYvEiOL2lY1kBxZtaYsyvjp7cmWOU1XsAdfQBwClraEofXDT197lFbXOEVMHpvQOg==", + "devOptional": true, + "license": "MIT" + }, "node_modules/has-bigints": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", @@ -12118,6 +12595,16 @@ "hulk": "bin/hulk" } }, + "node_modules/hono": { + "version": "4.12.14", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.14.tgz", + "integrity": "sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, "node_modules/href-content": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/href-content/-/href-content-2.0.3.tgz", @@ -12172,6 +12659,13 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/http-status-codes": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/http-status-codes/-/http-status-codes-2.3.0.tgz", + "integrity": "sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA==", + "devOptional": true, + "license": "MIT" + }, "node_modules/hunspell-asm": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/hunspell-asm/-/hunspell-asm-4.0.2.tgz", @@ -12705,6 +13199,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-property": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", + "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", + "devOptional": true, + "license": "MIT" + }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -12880,7 +13381,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, + "devOptional": true, "license": "ISC" }, "node_modules/isomorphic.js": { @@ -13386,6 +13887,13 @@ "dev": true, "license": "MIT" }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "devOptional": true, + "license": "Apache-2.0" + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -13424,6 +13932,22 @@ "yallist": "^3.0.2" } }, + "node_modules/lru.min": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.4.tgz", + "integrity": "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==", + "devOptional": true, + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=1.30.0", + "node": ">=8.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wellwelwel" + } + }, "node_modules/lucide-react": { "version": "0.562.0", "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.562.0.tgz", @@ -13680,6 +14204,57 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/mysql2": { + "version": "3.15.3", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.15.3.tgz", + "integrity": "sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "aws-ssl-profiles": "^1.1.1", + "denque": "^2.1.0", + "generate-function": "^2.3.1", + "iconv-lite": "^0.7.0", + "long": "^5.2.1", + "lru.min": "^1.0.0", + "named-placeholders": "^1.1.3", + "seq-queue": "^0.0.5", + "sqlstring": "^2.3.2" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/mysql2/node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/named-placeholders": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz", + "integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "lru.min": "^1.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/nanoid": { "version": "5.1.7", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.7.tgz", @@ -14025,25 +14600,30 @@ } }, "node_modules/nypm": { - "version": "0.6.2", - "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.2.tgz", - "integrity": "sha512-7eM+hpOtrKrBDCh7Ypu2lJ9Z7PNZBdi/8AT3AX8xoCj43BBVHD0hPSTEvMtkMpfs8FCqBGhxB+uToIQimA111g==", + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.5.tgz", + "integrity": "sha512-K6AJy1GMVyfyMXRVB88700BJqNUkByijGJM8kEHpLdcAt+vSQAVfkWWHYzuRXHSY6xA2sNc5RjTj0p9rE2izVQ==", "devOptional": true, "license": "MIT", "dependencies": { - "citty": "^0.1.6", - "consola": "^3.4.2", + "citty": "^0.2.0", "pathe": "^2.0.3", - "pkg-types": "^2.3.0", - "tinyexec": "^1.0.1" + "tinyexec": "^1.0.2" }, "bin": { "nypm": "dist/cli.mjs" }, "engines": { - "node": "^14.16.0 || >=16.10.0" + "node": ">=18" } }, + "node_modules/nypm/node_modules/citty": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/citty/-/citty-0.2.2.tgz", + "integrity": "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==", + "devOptional": true, + "license": "MIT" + }, "node_modules/oauth4webapi": { "version": "3.8.5", "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.5.tgz", @@ -14496,7 +15076,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=8" @@ -14612,6 +15192,104 @@ "license": "MIT", "optional": true }, + "node_modules/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.12.0", + "pg-pool": "^3.13.0", + "pg-protocol": "^1.13.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.3.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz", + "integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.12.0.tgz", + "integrity": "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.13.0.tgz", + "integrity": "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz", + "integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pg-types/node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, "node_modules/pick-util": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/pick-util/-/pick-util-1.1.5.tgz", @@ -14877,6 +15555,59 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/postgres": { + "version": "3.4.7", + "resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.7.tgz", + "integrity": "sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw==", + "devOptional": true, + "license": "Unlicense", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/porsager" + } + }, + "node_modules/postgres-array": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-3.0.4.tgz", + "integrity": "sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/preact": { "version": "10.24.3", "resolved": "https://registry.npmjs.org/preact/-/preact-10.24.3.tgz", @@ -14942,26 +15673,34 @@ "license": "MIT" }, "node_modules/prisma": { - "version": "6.19.1", - "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.19.1.tgz", - "integrity": "sha512-XRfmGzh6gtkc/Vq3LqZJcS2884dQQW3UhPo6jNRoiTW95FFQkXFg8vkYEy6og+Pyv0aY7zRQ7Wn1Cvr56XjhQQ==", + "version": "7.7.0", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-7.7.0.tgz", + "integrity": "sha512-HlgwRBt1uEFB9LStHL4HLYDvoi4BNu1rYA0hPG0zCAEyK9SaZBqp7E5Rjpc3Qh8Lex/ye/svoHZ0OWoFNhWxuQ==", "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "@prisma/config": "6.19.1", - "@prisma/engines": "6.19.1" + "@prisma/config": "7.7.0", + "@prisma/dev": "0.24.3", + "@prisma/engines": "7.7.0", + "@prisma/studio-core": "0.27.3", + "mysql2": "3.15.3", + "postgres": "3.4.7" }, "bin": { "prisma": "build/index.js" }, "engines": { - "node": ">=18.18" + "node": "^20.19 || ^22.12 || >=24.0" }, "peerDependencies": { - "typescript": ">=5.1.0" + "better-sqlite3": ">=9.0.0", + "typescript": ">=5.4.0" }, "peerDependenciesMeta": { + "better-sqlite3": { + "optional": true + }, "typescript": { "optional": true } @@ -14977,6 +15716,19 @@ "node": ">= 0.6.0" } }, + "node_modules/prom-client": { + "version": "15.1.3", + "resolved": "https://registry.npmjs.org/prom-client/-/prom-client-15.1.3.tgz", + "integrity": "sha512-6ZiOBfCywsD4k1BN9IX0uZhF+tJkV8q8llP64G5Hajs4JOeVLPCwpPVcpXy3BwYiUGgyJzsJJQeOIv7+hDSq8g==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.4.0", + "tdigest": "^0.1.1" + }, + "engines": { + "node": "^16 || ^18 || >=20" + } + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -14989,6 +15741,25 @@ "react-is": "^16.13.1" } }, + "node_modules/proper-lockfile": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + } + }, + "node_modules/proper-lockfile/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "devOptional": true, + "license": "ISC" + }, "node_modules/prosemirror-changeset": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.3.1.tgz", @@ -15498,6 +16269,16 @@ "regjsparser": "bin/parser" } }, + "node_modules/remeda": { + "version": "2.33.4", + "resolved": "https://registry.npmjs.org/remeda/-/remeda-2.33.4.tgz", + "integrity": "sha512-ygHswjlc/opg2VrtiYvUOPLjxjtdKvjGz1/plDhkG66hjNjFr1xmfrs2ClNFo/E6TyUFiwYNh53bKV26oBoMGQ==", + "devOptional": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/remeda" + } + }, "node_modules/remote-content": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/remote-content/-/remote-content-4.0.1.tgz", @@ -15522,6 +16303,16 @@ "range-parser": "^1.2.1" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/reserved-words": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/reserved-words/-/reserved-words-0.1.2.tgz", @@ -15569,6 +16360,16 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -15800,6 +16601,12 @@ "semver": "bin/semver.js" } }, + "node_modules/seq-queue": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz", + "integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==", + "devOptional": true + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -15909,7 +16716,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -15922,7 +16729,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=8" @@ -16011,6 +16818,19 @@ "dev": true, "license": "ISC" }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "devOptional": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/simple-xml-to-json": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/simple-xml-to-json/-/simple-xml-to-json-1.2.3.tgz", @@ -16094,6 +16914,25 @@ "specificity": "bin/specificity" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/sqlstring": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz", + "integrity": "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/stable-hash": { "version": "0.0.5", "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", @@ -16122,7 +16961,7 @@ "version": "3.10.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/stop-iteration-iterator": { @@ -16511,6 +17350,15 @@ "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/tdigest": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/tdigest/-/tdigest-0.1.2.tgz", + "integrity": "sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA==", + "license": "MIT", + "dependencies": { + "bintrees": "1.0.2" + } + }, "node_modules/text-segmentation": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", @@ -16542,9 +17390,9 @@ "license": "MIT" }, "node_modules/tinyexec": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", - "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.1.tgz", + "integrity": "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==", "devOptional": true, "license": "MIT", "engines": { @@ -17051,7 +17899,6 @@ "version": "5.26.5", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "devOptional": true, "license": "MIT" }, "node_modules/unenv": { @@ -17302,6 +18149,21 @@ "uuid": "dist-node/bin/uuid" } }, + "node_modules/valibot": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.2.0.tgz", + "integrity": "sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg==", + "devOptional": true, + "license": "MIT", + "peerDependencies": { + "typescript": ">=5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/vite": { "version": "6.4.1", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", @@ -18136,7 +18998,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, + "devOptional": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -18390,6 +19252,15 @@ "sax": "^1.2.4" } }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, "node_modules/y-indexeddb": { "version": "9.0.12", "resolved": "https://registry.npmjs.org/y-indexeddb/-/y-indexeddb-9.0.12.tgz", @@ -18566,6 +19437,17 @@ "url": "https://opencollective.com/express" } }, + "node_modules/zeptomatch": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/zeptomatch/-/zeptomatch-2.1.0.tgz", + "integrity": "sha512-KiGErG2J0G82LSpniV0CtIzjlJ10E04j02VOudJsPyPwNZgGnRKQy7I1R7GMyg/QswnE4l7ohSGrQbQbjXPPDA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "grammex": "^3.1.11", + "graphmatch": "^1.1.0" + } + }, "node_modules/zod": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/zod/-/zod-4.2.1.tgz", diff --git a/package.json b/package.json index 3b0736cf..61e0385b 100644 --- a/package.json +++ b/package.json @@ -2,13 +2,14 @@ "name": "scriptio", "private": true, "version": "2.1.0", + "type": "module", "scripts": { "postinstall": "patch-package", "dev": "docker compose --profile dev up -d --wait && npx prisma db push && next dev", "build": "npx prisma generate && next build", - "build:tauri": "npx prisma generate && npx tsx src/build-tauri.ts", - "worker:dev": "wrangler dev -c src/lib/collaboration/wrangler.toml", - "worker:deploy": "wrangler deploy -c src/lib/collaboration/wrangler.toml", + "build:tauri": "npx prisma generate && npx tsx scripts/build-tauri.ts", + "worker:dev": "wrangler dev -c src/lib/cloud/wrangler.toml", + "worker:deploy": "wrangler deploy -c src/lib/cloud/wrangler.toml", "stripe:dev": "stripe listen --forward-to localhost:3000/api/webhooks/stripe", "tauri": "tauri", "tauri:dev": "tauri dev", @@ -32,7 +33,8 @@ "@aws-sdk/s3-request-presigner": "^3.952.0", "@choochmeque/tauri-plugin-iap-api": "^0.8.2", "@formkit/auto-animate": "^0.7.0", - "@prisma/client": "^6.19.0", + "@prisma/adapter-pg": "^7.8.0", + "@prisma/client": "^7.7.0", "@svgr/core": "^8.1.0", "@tauri-apps/api": "2.10.1", "@tauri-apps/plugin-dialog": "2.6.0", @@ -69,6 +71,8 @@ "next-themes": "^0.2.0", "nodemailer": "^7.0.12", "pdfmake": "^0.2.9", + "pg": "^8.20.0", + "prom-client": "^15.1.3", "react": "^19.2.4", "react-chartjs-2": "^5.2.0", "react-dom": "^19.2.4", @@ -94,6 +98,7 @@ "@types/node": "20.11.0", "@types/nodemailer": "^6.4.14", "@types/pdfmake": "^0.2.12", + "@types/pg": "^8.20.0", "@types/react": "^18.3.27", "@types/react-dom": "^18.3.7", "@types/uuid": "^10.0.0", @@ -108,9 +113,9 @@ "eslint-plugin-react-hooks": "^7.0.1", "patch-package": "^8.0.1", "playwright": "^1.58.2", - "prisma": "^6.19.0", + "prisma": "^7.7.0", "tsx": "^4.21.0", - "typescript": "^5.1.3", + "typescript": "^5.7.3", "typescript-plugin-css-modules": "^5.2.0", "vite": "^6.4.1", "vitest": "^3.2.4", diff --git a/prisma.config.ts b/prisma.config.ts new file mode 100644 index 00000000..6bab5632 --- /dev/null +++ b/prisma.config.ts @@ -0,0 +1,14 @@ +import "dotenv/config"; +import { defineConfig } from "prisma/config"; + +export default defineConfig({ + schema: "prisma/schema.prisma", + datasource: { + // env() throws if the variable is absent (even for generate-only commands). + // process.env with a fallback keeps prisma generate working in CI without a DB. + url: process.env.DATABASE_URL ?? "postgresql://localhost/build", + }, + migrations: { + seed: "tsx prisma/seed.ts", + }, +}); diff --git a/prisma/migrations/20260422105711_init/migration.sql b/prisma/migrations/20260422105711_init/migration.sql deleted file mode 100644 index 061247ac..00000000 --- a/prisma/migrations/20260422105711_init/migration.sql +++ /dev/null @@ -1,168 +0,0 @@ --- CreateEnum -CREATE TYPE "ProjectRole" AS ENUM ('OWNER', 'ADMIN', 'EDITOR', 'VIEWER'); - --- CreateEnum -CREATE TYPE "SubscriptionProvider" AS ENUM ('STRIPE', 'APPLE'); - --- CreateTable -CREATE TABLE "User" ( - "id" TEXT NOT NULL, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "email" TEXT NOT NULL, - "emailVerified" TIMESTAMP(3), - "username" TEXT, - "color" TEXT, - "isProUntil" TIMESTAMP(3), - "isSubscriptionCancelled" BOOLEAN NOT NULL DEFAULT false, - "subscriptionProvider" "SubscriptionProvider", - "settings" JSONB, - - CONSTRAINT "User_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "Account" ( - "id" TEXT NOT NULL, - "userId" TEXT NOT NULL, - "type" TEXT NOT NULL, - "provider" TEXT NOT NULL, - "providerAccountId" TEXT NOT NULL, - "refresh_token" TEXT, - "access_token" TEXT, - "expires_at" INTEGER, - "token_type" TEXT, - "scope" TEXT, - "id_token" TEXT, - "session_state" TEXT, - - CONSTRAINT "Account_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "Session" ( - "id" TEXT NOT NULL, - "sessionToken" TEXT NOT NULL, - "userId" TEXT NOT NULL, - "expires" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "Session_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "VerificationToken" ( - "identifier" TEXT NOT NULL, - "token" TEXT NOT NULL, - "expires" TIMESTAMP(3) NOT NULL -); - --- CreateTable -CREATE TABLE "MagicLinkToken" ( - "id" SERIAL NOT NULL, - "email" TEXT NOT NULL, - "tokenHash" TEXT NOT NULL, - "expiresAt" TIMESTAMP(3) NOT NULL, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "desktopNonce" TEXT, - "inviteToken" TEXT, - - CONSTRAINT "MagicLinkToken_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "Transaction" ( - "id" SERIAL NOT NULL, - "userId" TEXT NOT NULL, - "provider" "SubscriptionProvider" NOT NULL, - "transactionId" TEXT NOT NULL, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - - CONSTRAINT "Transaction_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "Project" ( - "id" TEXT NOT NULL, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, - "title" TEXT NOT NULL, - "description" TEXT, - "author" TEXT, - "hasPoster" BOOLEAN NOT NULL DEFAULT false, - - CONSTRAINT "Project_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "ProjectMember" ( - "id" SERIAL NOT NULL, - "role" "ProjectRole" NOT NULL DEFAULT 'VIEWER', - "userId" TEXT NOT NULL, - "projectId" TEXT NOT NULL, - - CONSTRAINT "ProjectMember_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "ProjectInvitation" ( - "id" SERIAL NOT NULL, - "token" TEXT NOT NULL, - "email" TEXT NOT NULL, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "projectId" TEXT NOT NULL, - - CONSTRAINT "ProjectInvitation_pkey" PRIMARY KEY ("id") -); - --- CreateIndex -CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); - --- CreateIndex -CREATE UNIQUE INDEX "Account_provider_providerAccountId_key" ON "Account"("provider", "providerAccountId"); - --- CreateIndex -CREATE UNIQUE INDEX "Session_sessionToken_key" ON "Session"("sessionToken"); - --- CreateIndex -CREATE UNIQUE INDEX "VerificationToken_token_key" ON "VerificationToken"("token"); - --- CreateIndex -CREATE UNIQUE INDEX "VerificationToken_identifier_token_key" ON "VerificationToken"("identifier", "token"); - --- CreateIndex -CREATE UNIQUE INDEX "MagicLinkToken_tokenHash_key" ON "MagicLinkToken"("tokenHash"); - --- CreateIndex -CREATE INDEX "MagicLinkToken_email_createdAt_idx" ON "MagicLinkToken"("email", "createdAt"); - --- CreateIndex -CREATE INDEX "Transaction_userId_idx" ON "Transaction"("userId"); - --- CreateIndex -CREATE INDEX "Transaction_transactionId_idx" ON "Transaction"("transactionId"); - --- CreateIndex -CREATE UNIQUE INDEX "ProjectMember_userId_projectId_key" ON "ProjectMember"("userId", "projectId"); - --- CreateIndex -CREATE UNIQUE INDEX "ProjectInvitation_token_key" ON "ProjectInvitation"("token"); - --- CreateIndex -CREATE UNIQUE INDEX "ProjectInvitation_email_projectId_key" ON "ProjectInvitation"("email", "projectId"); - --- AddForeignKey -ALTER TABLE "Account" ADD CONSTRAINT "Account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "Transaction" ADD CONSTRAINT "Transaction_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "ProjectMember" ADD CONSTRAINT "ProjectMember_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "ProjectMember" ADD CONSTRAINT "ProjectMember_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "ProjectInvitation" ADD CONSTRAINT "ProjectInvitation_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml deleted file mode 100644 index 044d57cd..00000000 --- a/prisma/migrations/migration_lock.toml +++ /dev/null @@ -1,3 +0,0 @@ -# Please do not edit this file manually -# It should be added in your version-control system (e.g., Git) -provider = "postgresql" diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 8115d82b..8ee20c7e 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -2,12 +2,12 @@ // learn more about it in the docs: https://pris.ly/d/prisma-schema generator client { - provider = "prisma-client-js" + provider = "prisma-client" + output = "../src/generated/client" } datasource db { provider = "postgresql" - url = env("DATABASE_URL") } enum ProjectRole { @@ -17,6 +17,11 @@ enum ProjectRole { VIEWER } +enum UserRole { + USER + ADMIN +} + enum SubscriptionProvider { STRIPE APPLE @@ -29,6 +34,7 @@ model User { emailVerified DateTime? username String? color String? + role UserRole @default(USER) isProUntil DateTime? isSubscriptionCancelled Boolean @default(false) diff --git a/prisma/seed.ts b/prisma/seed.ts new file mode 100644 index 00000000..1094a2ff --- /dev/null +++ b/prisma/seed.ts @@ -0,0 +1,38 @@ +import "dotenv/config"; +import { PrismaClient, UserRole } from "../src/generated/client/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() { + const adminEmail = process.env.SEED_ADMIN_EMAIL; + + if (!adminEmail) { + console.log("No SEED_ADMIN_EMAIL set — skipping admin promotion."); + return; + } + + const user = await prisma.user.upsert({ + where: { email: adminEmail }, + update: { role: UserRole.ADMIN }, + create: { + email: adminEmail, + emailVerified: new Date(), + role: UserRole.ADMIN, + }, + }); + + console.log(`Admin set: ${user.email} (id: ${user.id})`); +} + +main() + .catch((e) => { + console.error(e); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/src/build-tauri.ts b/scripts/build-tauri.ts similarity index 53% rename from src/build-tauri.ts rename to scripts/build-tauri.ts index fe6d8f92..b4a57872 100644 --- a/src/build-tauri.ts +++ b/scripts/build-tauri.ts @@ -3,20 +3,28 @@ import { join } from "path"; import { execSync } from "child_process"; const apiDir = join("src", "app", "api"); -const hiddenDir = join("src", "app", "_api"); +const hiddenApiDir = join("src", "app", "_api"); +const adminDir = join("src", "app", "admin"); +const hiddenAdminDir = join("src", "app", "_admin"); // Clean .next cache to avoid stale type references to API routes rmSync(".next", { recursive: true, force: true }); // Prefix with _ so Next.js ignores the API routes during static export if (existsSync(apiDir)) { - renameSync(apiDir, hiddenDir); + renameSync(apiDir, hiddenApiDir); +} +if (existsSync(adminDir)) { + renameSync(adminDir, hiddenAdminDir); } try { execSync("npx cross-env TAURI_BUILD=true next build", { stdio: "inherit" }); } finally { - if (existsSync(hiddenDir)) { - renameSync(hiddenDir, apiDir); + if (existsSync(hiddenApiDir)) { + renameSync(hiddenApiDir, apiDir); + } + if (existsSync(hiddenAdminDir)) { + renameSync(hiddenAdminDir, adminDir); } } diff --git a/scripts/generate-apple-jwt.ts b/scripts/generate-apple-jwt.ts index fdb971ca..600ec68f 100644 --- a/scripts/generate-apple-jwt.ts +++ b/scripts/generate-apple-jwt.ts @@ -35,8 +35,7 @@ export async function generateAppleJWT({ } // --- CLI Execution Logic --- -// In TS, we check if this file is the entry point differently depending on your runner -if (require.main === module || process.argv[1]?.includes("generate-apple-jwt")) { +if (process.argv[1]?.includes("generate-apple-jwt")) { const config: AppleAuthConfig = { teamId: process.env.AUTH_APPLE_TEAM_ID!, clientId: process.env.AUTH_APPLE_CLIENT_ID!, diff --git a/launch.sh b/scripts/launch.sh old mode 100755 new mode 100644 similarity index 100% rename from launch.sh rename to scripts/launch.sh diff --git a/src/app/admin/layout.tsx b/src/app/admin/layout.tsx new file mode 100644 index 00000000..d486309f --- /dev/null +++ b/src/app/admin/layout.tsx @@ -0,0 +1,15 @@ +import { ReactNode } from "react"; +import { redirect } from "next/navigation"; +import { UserRole } from "../../generated/client/client"; +import { auth } from "@src/auth"; + +export default async function AdminLayout({ children }: { children: ReactNode }) { + const session = await auth(); + const role = (session?.user as unknown as { role?: UserRole } | undefined)?.role; + + if (!session?.user || role !== UserRole.ADMIN) { + redirect("/"); + } + + return <>{children}; +} diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx new file mode 100644 index 00000000..56f39e72 --- /dev/null +++ b/src/app/admin/page.tsx @@ -0,0 +1,14 @@ +import { auth } from "@src/auth"; +import AdminShell from "@components/admin/AdminShell"; +import StatsCards from "@components/admin/StatsCards"; + +export default async function AdminHomePage() { + const session = await auth(); + const email = session?.user?.email ?? ""; + + return ( + + + + ); +} diff --git a/src/app/admin/projects/[projectId]/page.tsx b/src/app/admin/projects/[projectId]/page.tsx new file mode 100644 index 00000000..950d41dd --- /dev/null +++ b/src/app/admin/projects/[projectId]/page.tsx @@ -0,0 +1,16 @@ +import { auth } from "@src/auth"; +import AdminShell from "@components/admin/AdminShell"; +import ProjectDetail from "@components/admin/ProjectDetail"; + +type Props = { params: Promise<{ projectId: string }> }; + +export default async function AdminProjectDetailPage({ params }: Props) { + const [session, { projectId }] = await Promise.all([auth(), params]); + const email = session?.user?.email ?? ""; + + return ( + + + + ); +} diff --git a/src/app/admin/projects/page.tsx b/src/app/admin/projects/page.tsx new file mode 100644 index 00000000..ce0e40b7 --- /dev/null +++ b/src/app/admin/projects/page.tsx @@ -0,0 +1,14 @@ +import { auth } from "@src/auth"; +import AdminShell from "@components/admin/AdminShell"; +import ProjectSearch from "@components/admin/ProjectSearch"; + +export default async function AdminProjectsPage() { + const session = await auth(); + const email = session?.user?.email ?? ""; + + return ( + + + + ); +} diff --git a/src/app/admin/users/[userId]/page.tsx b/src/app/admin/users/[userId]/page.tsx new file mode 100644 index 00000000..f2853441 --- /dev/null +++ b/src/app/admin/users/[userId]/page.tsx @@ -0,0 +1,17 @@ +import { auth } from "@src/auth"; +import AdminShell from "@components/admin/AdminShell"; +import UserDetail from "@components/admin/UserDetail"; + +type Props = { params: Promise<{ userId: string }> }; + +export default async function AdminUserDetailPage({ params }: Props) { + const session = await auth(); + const email = session?.user?.email ?? ""; + const { userId } = await params; + + return ( + + + + ); +} diff --git a/src/app/admin/users/page.tsx b/src/app/admin/users/page.tsx new file mode 100644 index 00000000..92cc9fb7 --- /dev/null +++ b/src/app/admin/users/page.tsx @@ -0,0 +1,14 @@ +import { auth } from "@src/auth"; +import AdminShell from "@components/admin/AdminShell"; +import UserSearch from "@components/admin/UserSearch"; + +export default async function AdminUsersPage() { + const session = await auth(); + const email = session?.user?.email ?? ""; + + return ( + + + + ); +} diff --git a/src/app/api/admin/projects/[projectId]/invites/route.ts b/src/app/api/admin/projects/[projectId]/invites/route.ts new file mode 100644 index 00000000..850f845e --- /dev/null +++ b/src/app/api/admin/projects/[projectId]/invites/route.ts @@ -0,0 +1,19 @@ +import { NextRequest } from "next/server"; +import z from "zod"; + +import * as ProjectService from "@src/server/service/project-service"; +import { Success, validate } from "@src/lib/utils/api-utils"; +import { apiHandler, AuthApiContext } from "@src/lib/utils/api-handler"; +import { assertAdmin } from "@src/lib/utils/admin-guard"; + +const ParamsSchema = z.object({ projectId: z.string().min(1) }); + +async function getProjectInvites(req: NextRequest, { routeParams, user }: AuthApiContext) { + await assertAdmin(user); + const { projectId } = validate(ParamsSchema, routeParams); + + const invites = await ProjectService.getInvitesWithMeta(projectId); + return Success(invites); +} + +export const GET = apiHandler(getProjectInvites); diff --git a/src/app/api/admin/projects/[projectId]/members/route.ts b/src/app/api/admin/projects/[projectId]/members/route.ts new file mode 100644 index 00000000..4c76dd8c --- /dev/null +++ b/src/app/api/admin/projects/[projectId]/members/route.ts @@ -0,0 +1,19 @@ +import { NextRequest } from "next/server"; +import z from "zod"; + +import * as ProjectService from "@src/server/service/project-service"; +import { Success, validate } from "@src/lib/utils/api-utils"; +import { apiHandler, AuthApiContext } from "@src/lib/utils/api-handler"; +import { assertAdmin } from "@src/lib/utils/admin-guard"; + +const ParamsSchema = z.object({ projectId: z.string().min(1) }); + +async function getProjectMembers(req: NextRequest, { routeParams, user }: AuthApiContext) { + await assertAdmin(user); + const { projectId } = validate(ParamsSchema, routeParams); + + const collaborators = await ProjectService.getCollaborators(projectId); + return Success(collaborators); +} + +export const GET = apiHandler(getProjectMembers); diff --git a/src/app/api/admin/projects/[projectId]/route.ts b/src/app/api/admin/projects/[projectId]/route.ts new file mode 100644 index 00000000..b0da7c0d --- /dev/null +++ b/src/app/api/admin/projects/[projectId]/route.ts @@ -0,0 +1,21 @@ +import { NextRequest } from "next/server"; +import z from "zod"; + +import * as ProjectService from "@src/server/service/project-service"; +import { Success, validate, NotFoundError } from "@src/lib/utils/api-utils"; +import { apiHandler, AuthApiContext } from "@src/lib/utils/api-handler"; +import { assertAdmin } from "@src/lib/utils/admin-guard"; + +const ParamsSchema = z.object({ projectId: z.string().min(1) }); + +async function getProject(req: NextRequest, { routeParams, user }: AuthApiContext) { + await assertAdmin(user); + const { projectId } = validate(ParamsSchema, routeParams); + + const project = await ProjectService.getProjectById(projectId); + if (!project) throw new NotFoundError(); + + return Success(project); +} + +export const GET = apiHandler(getProject); diff --git a/src/app/api/admin/projects/route.ts b/src/app/api/admin/projects/route.ts new file mode 100644 index 00000000..003a394f --- /dev/null +++ b/src/app/api/admin/projects/route.ts @@ -0,0 +1,33 @@ +import { NextRequest } from "next/server"; +import z from "zod"; + +import * as ProjectService from "@src/server/service/project-service"; +import { Success, validate } from "@src/lib/utils/api-utils"; +import { apiHandler, AuthApiContext } from "@src/lib/utils/api-handler"; +import { assertAdmin } from "@src/lib/utils/admin-guard"; + +const QuerySchema = z.object({ + q: z.string().trim().min(1).max(256).optional(), + limit: z.coerce.number().int().min(1).max(100).optional(), + cursor: z.coerce.number().int().min(0).optional(), +}); + +async function searchProjects(req: NextRequest, { searchParams, user }: AuthApiContext) { + await assertAdmin(user); + + const { q, limit = 25, cursor } = validate(QuerySchema, searchParams); + const term = (q ?? "").trim(); + + if (!term) { + return Success({ projects: [], nextCursor: null }); + } + + const projects = await ProjectService.searchProjects(term, limit + 1, cursor); + const hasMore = projects.length > limit; + const page = hasMore ? projects.slice(0, limit) : projects; + const nextCursor = hasMore ? (cursor ?? 0) + limit : null; + + return Success({ projects: page, nextCursor }); +} + +export const GET = apiHandler(searchProjects); diff --git a/src/app/api/admin/stats/route.ts b/src/app/api/admin/stats/route.ts new file mode 100644 index 00000000..2a9e3cf8 --- /dev/null +++ b/src/app/api/admin/stats/route.ts @@ -0,0 +1,32 @@ +import { NextRequest } from "next/server"; + +import * as UserService from "@src/server/service/user-service"; +import * as ProjectService from "@src/server/service/project-service"; +import * as TransactionService from "@src/server/service/transaction-service"; +import { Success } from "@src/lib/utils/api-utils"; +import { apiHandler, AuthApiContext } from "@src/lib/utils/api-handler"; +import { assertAdmin } from "@src/lib/utils/admin-guard"; + +async function getStats(req: NextRequest, { user }: AuthApiContext) { + await assertAdmin(user); + + const startOfMonth = new Date(); + startOfMonth.setUTCDate(1); + startOfMonth.setUTCHours(0, 0, 0, 0); + + const [userCount, activeProCount, projectCount, transactionsThisMonth] = await Promise.all([ + UserService.countUsers(), + UserService.countActiveProUsers(), + ProjectService.countProjects(), + TransactionService.countTransactionsSince(startOfMonth), + ]); + + return Success({ + userCount, + activeProCount, + projectCount, + transactionsThisMonth, + }); +} + +export const GET = apiHandler(getStats); diff --git a/src/app/api/admin/users/[userId]/projects/route.ts b/src/app/api/admin/users/[userId]/projects/route.ts new file mode 100644 index 00000000..b78f4297 --- /dev/null +++ b/src/app/api/admin/users/[userId]/projects/route.ts @@ -0,0 +1,19 @@ +import { NextRequest } from "next/server"; +import z from "zod"; + +import * as ProjectService from "@src/server/service/project-service"; +import { Success, validate } from "@src/lib/utils/api-utils"; +import { apiHandler, AuthApiContext } from "@src/lib/utils/api-handler"; +import { assertAdmin } from "@src/lib/utils/admin-guard"; + +const ParamsSchema = z.object({ userId: z.string().min(1) }); + +async function getUserProjects(req: NextRequest, { routeParams, user }: AuthApiContext) { + await assertAdmin(user); + const { userId } = validate(ParamsSchema, routeParams); + + const memberships = await ProjectService.getMemberships(userId); + return Success(memberships ?? []); +} + +export const GET = apiHandler(getUserProjects); diff --git a/src/app/api/admin/users/[userId]/route.ts b/src/app/api/admin/users/[userId]/route.ts new file mode 100644 index 00000000..8d79a2c7 --- /dev/null +++ b/src/app/api/admin/users/[userId]/route.ts @@ -0,0 +1,42 @@ +import { NextRequest } from "next/server"; +import z from "zod"; + +import * as UserService from "@src/server/service/user-service"; +import * as ProjectService from "@src/server/service/project-service"; +import * as TransactionService from "@src/server/service/transaction-service"; +import { Success, UserNotFoundError, validate } from "@src/lib/utils/api-utils"; +import { apiHandler, AuthApiContext } from "@src/lib/utils/api-handler"; +import { assertAdmin } from "@src/lib/utils/admin-guard"; + +const ParamsSchema = z.object({ userId: z.string().min(1) }); + +async function getUserDetail(req: NextRequest, { routeParams, user }: AuthApiContext) { + await assertAdmin(user); + const { userId } = validate(ParamsSchema, routeParams); + + const target = await UserService.getUserFromId(userId); + if (!target) throw new UserNotFoundError(); + + const [projectCount, transactionCount] = await Promise.all([ + ProjectService.countMembershipsByUser(userId), + TransactionService.countTransactionsByUser(userId), + ]); + + return Success({ + user: { + id: target.id, + email: target.email, + createdAt: target.createdAt, + emailVerified: target.emailVerified, + username: target.username, + role: target.role, + isProUntil: target.isProUntil, + isSubscriptionCancelled: target.isSubscriptionCancelled, + subscriptionProvider: target.subscriptionProvider, + }, + projectCount, + transactionCount, + }); +} + +export const GET = apiHandler(getUserDetail); diff --git a/src/app/api/admin/users/[userId]/transactions/route.ts b/src/app/api/admin/users/[userId]/transactions/route.ts new file mode 100644 index 00000000..a5881d08 --- /dev/null +++ b/src/app/api/admin/users/[userId]/transactions/route.ts @@ -0,0 +1,19 @@ +import { NextRequest } from "next/server"; +import z from "zod"; + +import * as TransactionService from "@src/server/service/transaction-service"; +import { Success, validate } from "@src/lib/utils/api-utils"; +import { apiHandler, AuthApiContext } from "@src/lib/utils/api-handler"; +import { assertAdmin } from "@src/lib/utils/admin-guard"; + +const ParamsSchema = z.object({ userId: z.string().min(1) }); + +async function getUserTransactions(req: NextRequest, { routeParams, user }: AuthApiContext) { + await assertAdmin(user); + const { userId } = validate(ParamsSchema, routeParams); + + const transactions = await TransactionService.getTransactionsByUser(userId); + return Success(transactions); +} + +export const GET = apiHandler(getUserTransactions); diff --git a/src/app/api/admin/users/route.ts b/src/app/api/admin/users/route.ts new file mode 100644 index 00000000..c134b4bb --- /dev/null +++ b/src/app/api/admin/users/route.ts @@ -0,0 +1,33 @@ +import { NextRequest } from "next/server"; +import z from "zod"; + +import * as UserService from "@src/server/service/user-service"; +import { Success, validate } from "@src/lib/utils/api-utils"; +import { apiHandler, AuthApiContext } from "@src/lib/utils/api-handler"; +import { assertAdmin } from "@src/lib/utils/admin-guard"; + +const QuerySchema = z.object({ + q: z.string().trim().min(1).max(256).optional(), + limit: z.coerce.number().int().min(1).max(100).optional(), + cursor: z.coerce.number().int().min(0).optional(), +}); + +async function searchUsers(req: NextRequest, { searchParams, user }: AuthApiContext) { + await assertAdmin(user); + + const { q, limit = 25, cursor } = validate(QuerySchema, searchParams); + const term = (q ?? "").trim(); + + if (!term) { + return Success({ users: [], nextCursor: null }); + } + + const users = await UserService.searchUsers(term, limit + 1, cursor); + const hasMore = users.length > limit; + const page = hasMore ? users.slice(0, limit) : users; + const nextCursor = hasMore ? (cursor ?? 0) + limit : null; + + return Success({ users: page, nextCursor }); +} + +export const GET = apiHandler(searchUsers); diff --git a/src/app/api/metrics/route.ts b/src/app/api/metrics/route.ts new file mode 100644 index 00000000..17645d93 --- /dev/null +++ b/src/app/api/metrics/route.ts @@ -0,0 +1,30 @@ +import { NextRequest, NextResponse } from "next/server"; +import { timingSafeEqual } from "crypto"; +import { registry } from "../../../lib/metrics/registry"; +import { startDbSizeCollector } from "../../../lib/metrics/db-size-collector"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +startDbSizeCollector(); + +const isAuthorized = (header: string | null): boolean => { + const expected = process.env.METRICS_BEARER_TOKEN; + if (!expected || !header?.startsWith("Bearer ")) return false; + const provided = header.slice("Bearer ".length); + const a = new Uint8Array(Buffer.from(provided)); + const b = new Uint8Array(Buffer.from(expected)); + if (a.length !== b.length) return false; + return timingSafeEqual(a, b); +}; + +export const GET = async (req: NextRequest) => { + if (!isAuthorized(req.headers.get("authorization"))) { + return new NextResponse("Unauthorized", { status: 401 }); + } + const body = await registry.metrics(); + return new NextResponse(body, { + status: 200, + headers: { "Content-Type": registry.contentType }, + }); +}; diff --git a/src/app/api/projects/[projectId]/invite/route.ts b/src/app/api/projects/[projectId]/invite/route.ts index 4879bb01..c6f3a99c 100644 --- a/src/app/api/projects/[projectId]/invite/route.ts +++ b/src/app/api/projects/[projectId]/invite/route.ts @@ -1,5 +1,5 @@ import { apiHandler, AuthApiContext } from "@src/lib/utils/api-handler"; -import { ProjectRole } from "@prisma/client"; +import { ProjectRole } from "../../../../../generated/client/client"; import { ForbiddenError, InternalServerError, diff --git a/src/app/api/projects/[projectId]/members/[userId]/route.ts b/src/app/api/projects/[projectId]/members/[userId]/route.ts index 8a89ae1f..3ad9c65d 100644 --- a/src/app/api/projects/[projectId]/members/[userId]/route.ts +++ b/src/app/api/projects/[projectId]/members/[userId]/route.ts @@ -1,4 +1,4 @@ -import { ProjectRole } from "@prisma/client"; +import { ProjectRole } from "../../../../../../generated/client/client"; import { apiHandler, AuthApiContext } from "@src/lib/utils/api-handler"; import { ForbiddenError, @@ -12,7 +12,7 @@ import { import * as Roles from "@src/lib/utils/roles"; import * as ProjectService from "@src/server/service/project-service"; -import * as CollabUtils from "@src/lib/collaboration/utils"; +import * as CollabUtils from "@src/lib/cloud/utils"; import z from "zod"; import { NextRequest } from "next/server"; diff --git a/src/app/api/projects/[projectId]/route.ts b/src/app/api/projects/[projectId]/route.ts index 9e39e2da..e88c9ac3 100644 --- a/src/app/api/projects/[projectId]/route.ts +++ b/src/app/api/projects/[projectId]/route.ts @@ -1,4 +1,4 @@ -import { ProjectRole } from "@prisma/client"; +import { ProjectRole } from "../../../../generated/client/client"; import * as S3 from "@src/lib/s3"; import * as ProjectService from "@src/server/service/project-service"; diff --git a/src/app/api/projects/[projectId]/saves/manual/route.ts b/src/app/api/projects/[projectId]/saves/manual/route.ts index ad318231..03628455 100644 --- a/src/app/api/projects/[projectId]/saves/manual/route.ts +++ b/src/app/api/projects/[projectId]/saves/manual/route.ts @@ -1,4 +1,4 @@ -import { ProjectRole } from "@prisma/client"; +import { ProjectRole } from "../../../../../../generated/client/client"; import { apiHandler, AuthApiContext } from "@src/lib/utils/api-handler"; import { ForbiddenError, diff --git a/src/app/api/projects/[projectId]/saves/restore/route.ts b/src/app/api/projects/[projectId]/saves/restore/route.ts index fcc4338e..baa5e054 100644 --- a/src/app/api/projects/[projectId]/saves/restore/route.ts +++ b/src/app/api/projects/[projectId]/saves/restore/route.ts @@ -1,4 +1,4 @@ -import { ProjectRole } from "@prisma/client"; +import { ProjectRole } from "../../../../../../generated/client/client"; import { apiHandler, AuthApiContext } from "@src/lib/utils/api-handler"; import { ForbiddenError, getCollabHttpUrl, Success, validate } from "@src/lib/utils/api-utils"; diff --git a/src/app/api/projects/[projectId]/saves/route.ts b/src/app/api/projects/[projectId]/saves/route.ts index 99ca56e1..b329c024 100644 --- a/src/app/api/projects/[projectId]/saves/route.ts +++ b/src/app/api/projects/[projectId]/saves/route.ts @@ -1,4 +1,4 @@ -import { ProjectRole } from "@prisma/client"; +import { ProjectRole } from "../../../../../generated/client/client"; import { apiHandler, AuthApiContext } from "@src/lib/utils/api-handler"; import { ForbiddenError, getCollabHttpUrl, Success, validate } from "@src/lib/utils/api-utils"; diff --git a/src/auth.ts b/src/auth.ts index 4510fcdb..d0bd650d 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -2,6 +2,7 @@ import NextAuth from "next-auth"; import Google from "next-auth/providers/google"; import Apple from "next-auth/providers/apple"; import { PrismaAdapter } from "@auth/prisma-adapter"; +import { UserRole } from "./generated/client/client"; import prisma from "@src/server/db"; import * as UserService from "@src/server/service/user-service"; @@ -26,13 +27,19 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ if (user) { token.id = user.id; token.email = user.email; - // `createdAt` is set on programmatic sign-in (string) and missing for OAuth — fill it in + // `createdAt` may be available on programmatic sign-in but not OAuth. const extUser = user as typeof user & { createdAt?: string }; if (extUser.createdAt) { token.createdAt = extUser.createdAt; - } else if (token.id && !token.createdAt) { - const dbUser = await UserService.getUserFromId(token.id as string); - if (dbUser) token.createdAt = dbUser.createdAt.toISOString(); + } + } + // Always sync from DB so role changes (e.g. granting admin) are reflected + // without requiring a sign-out. Called on every auth() invocation. + if (token.id) { + const dbUser = await UserService.getUserFromId(token.id as string); + if (dbUser) { + if (!token.createdAt) token.createdAt = dbUser.createdAt.toISOString(); + token.role = dbUser.role; } } return token; @@ -44,7 +51,13 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ id: token.id as string, email: token.email as string, createdAt: token.createdAt ? new Date(token.createdAt as string) : new Date(), - } as typeof session.user & { id: string; email: string; createdAt: Date }; + role: (token.role as UserRole) ?? UserRole.USER, + } as typeof session.user & { + id: string; + email: string; + createdAt: Date; + role: UserRole; + }; } return session; }, diff --git a/src/context/ProjectContext.tsx b/src/context/ProjectContext.tsx index c1e2144e..84511436 100644 --- a/src/context/ProjectContext.tsx +++ b/src/context/ProjectContext.tsx @@ -1,6 +1,14 @@ "use client"; -import { createContext, ReactNode, useCallback, useContext, useEffect, useMemo, useState } from "react"; +import { + createContext, + ReactNode, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from "react"; import { Editor } from "@tiptap/react"; import { CharacterMap, mergeCharactersData } from "@src/lib/screenplay/characters"; import { LocationMap, mergeLocationsData } from "@src/lib/screenplay/locations"; @@ -22,7 +30,7 @@ import { ScreenplayElement, TitlePageElement, Style, PageFormat } from "@src/lib import { SearchMatch } from "@src/lib/screenplay/extensions/search-highlight-extension"; // Import types only - these don't cause module loading -import type { ThrottledWebsocketProvider } from "@src/lib/collaboration/utils"; +import type { ThrottledWebsocketProvider } from "@src/lib/cloud/utils"; import type { ProjectRepository } from "@src/lib/project/project-repository"; // -------------------------------- // @@ -258,7 +266,9 @@ export const ProjectProvider = ({ children, projectId }: ProjectProviderProps) = const [characters, setCharacters] = useState(undefined); const [locations, setLocations] = useState(undefined); const [scenes, setScenes] = useState([]); - const [selectedElement, setSelectedElementState] = useState(ScreenplayElement.Action); + const [selectedElement, setSelectedElementState] = useState( + ScreenplayElement.Action, + ); const [selectedStyles, setSelectedStylesState] = useState