Consolidation pass: HR-only access, effective-dated mutations, data integrity guards, test suite
Reworks the app from a two-role (hr_admin/manager) model to a single HR-only role gated by profiles.is_active, fixes transfer/promote/karenz/ reorg RPCs to actually defer future-dated changes via a new pending_org_changes table instead of writing them immediately (applied by a daily Vercel Cron route), makes reorg undo append-only instead of deleting history, adds Karenz-return and history-date integrity guards, deprecates the salary column, and adds explicit schema grants + perf indexes needed to run against a fresh (non-hosted) Postgres instance. Adds vitest unit + integration test suites (the latter against a real local Supabase instance) covering all of the above, plus lint/typecheck/ build wiring (`npm run check`).
This commit is contained in:
@@ -2,7 +2,6 @@ import Link from "next/link";
|
||||
import { Suspense } from "react";
|
||||
import { AuditFilters } from "@/components/audit/AuditFilters";
|
||||
import { actionBadgeStyle } from "@/lib/colors";
|
||||
import { fmtDate } from "@/lib/format";
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
@@ -8,22 +8,16 @@ export default async function EmployeeDetailPage({ params }: PageProps) {
|
||||
const { id } = await params;
|
||||
const supabase = await createClient();
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
const { data: profile } = await supabase.from("profiles").select("role").eq("id", user!.id).single();
|
||||
const canEdit = profile?.role === "hr_admin";
|
||||
|
||||
const { data: employee } = await supabase.from("employees_directory").select("*").eq("id", id).single();
|
||||
const { data: employee } = await supabase.from("employees").select("*").eq("id", id).single();
|
||||
if (!employee) notFound();
|
||||
|
||||
const [{ data: manager }, { data: directReports }, { data: history }, { data: divisions }, { data: departments }, { data: teams }, { data: locations }, { data: openPositions }] =
|
||||
await Promise.all([
|
||||
employee.manager_id
|
||||
? supabase.from("employees_directory").select("id, first_name, last_name, job_title").eq("id", employee.manager_id).single()
|
||||
? supabase.from("employees").select("id, first_name, last_name, job_title").eq("id", employee.manager_id).single()
|
||||
: Promise.resolve({ data: null }),
|
||||
supabase
|
||||
.from("employees_directory")
|
||||
.from("employees")
|
||||
.select("id, first_name, last_name, job_title, status")
|
||||
.eq("manager_id", id)
|
||||
.order("last_name"),
|
||||
@@ -46,7 +40,6 @@ export default async function EmployeeDetailPage({ params }: PageProps) {
|
||||
teams={teams ?? []}
|
||||
locations={locations ?? []}
|
||||
openPositions={openPositions ?? []}
|
||||
canEdit={canEdit}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ export default async function EmployeesPage({ searchParams }: EmployeesPageProps
|
||||
const to = from + PAGE_SIZE - 1;
|
||||
|
||||
let query = supabase
|
||||
.from("employees_directory")
|
||||
.from("employees")
|
||||
.select(
|
||||
"id, first_name, last_name, personnel_number, job_title, team_id, division_id, location_id, entry_date, employment_type, weekly_hours, status",
|
||||
{ count: "exact" }
|
||||
|
||||
@@ -13,25 +13,27 @@ export default async function AppLayout({ children }: { children: ReactNode }) {
|
||||
} = await supabase.auth.getUser();
|
||||
if (!user) redirect("/login");
|
||||
|
||||
const { data: profile } = await supabase.from("profiles").select("full_name, email, role").eq("id", user.id).single();
|
||||
const canEdit = profile?.role === "hr_admin";
|
||||
// Defense in depth: proxy.ts already redirects any non-active-HR session
|
||||
// away before this layout ever renders. Re-checking here means a gap in
|
||||
// the proxy matcher (or a future route added outside it) still fails
|
||||
// closed instead of silently granting access — see docs/security.md.
|
||||
const { data: profile } = await supabase.from("profiles").select("full_name, email, role, is_active").eq("id", user.id).maybeSingle();
|
||||
if (profile?.role !== "hr" || profile?.is_active !== true) redirect("/login");
|
||||
|
||||
const userLabel = profile?.full_name || profile?.email || user.email || "";
|
||||
const userLabel = profile.full_name || profile.email || user.email || "";
|
||||
|
||||
const [openPositions, locationsRes, draftsRes] = canEdit
|
||||
? await Promise.all([
|
||||
loadOpenPositions(supabase),
|
||||
supabase.from("locations").select("id, name, country").order("name"),
|
||||
supabase.from("hire_drafts").select("id, step, payload, updated_at").eq("created_by", user.id).order("updated_at", { ascending: false }),
|
||||
])
|
||||
: [[], { data: [] }, { data: [] }];
|
||||
const [openPositions, locationsRes, draftsRes] = await Promise.all([
|
||||
loadOpenPositions(supabase),
|
||||
supabase.from("locations").select("id, name, country").order("name"),
|
||||
supabase.from("hire_drafts").select("id, step, payload, updated_at").eq("created_by", user.id).order("updated_at", { ascending: false }),
|
||||
]);
|
||||
|
||||
return (
|
||||
<HireWizardProvider openPositions={openPositions} locations={locationsRes.data ?? []} drafts={draftsRes.data ?? []}>
|
||||
<div className="flex min-h-screen">
|
||||
<Sidebar />
|
||||
<div className="ml-[236px] flex flex-1 flex-col">
|
||||
<Topbar userLabel={userLabel} role={profile?.role} canEdit={canEdit} />
|
||||
<Topbar userLabel={userLabel} />
|
||||
<main className="flex-1 px-6 py-6">
|
||||
<div className="mx-auto w-full max-w-[1280px]">{children}</div>
|
||||
</main>
|
||||
|
||||
@@ -14,7 +14,7 @@ export default async function OrgChartPage() {
|
||||
{ data: reorgScenarios },
|
||||
] = await Promise.all([
|
||||
supabase
|
||||
.from("employees_directory")
|
||||
.from("employees")
|
||||
.select("id, personnel_number, first_name, last_name, job_title, manager_id, team_id, division_id, is_lead, org_level")
|
||||
.in("status", ["Aktiv", "Karenz"]),
|
||||
supabase.from("divisions").select("*").order("name"),
|
||||
|
||||
@@ -67,36 +67,36 @@ export default async function DashboardPage() {
|
||||
upcomingReturnsRes,
|
||||
historyRes,
|
||||
] = await Promise.all([
|
||||
supabase.from("employees_directory").select("id", { count: "exact", head: true }).in("status", ["Aktiv", "Karenz"]),
|
||||
supabase.from("employees_directory").select("id", { count: "exact", head: true }).eq("status", "Karenz"),
|
||||
supabase.from("employees").select("id", { count: "exact", head: true }).in("status", ["Aktiv", "Karenz"]),
|
||||
supabase.from("employees").select("id", { count: "exact", head: true }).eq("status", "Karenz"),
|
||||
supabase
|
||||
.from("employees_directory")
|
||||
.from("employees")
|
||||
.select("id", { count: "exact", head: true })
|
||||
.gte("entry_date", yearStart)
|
||||
.lte("entry_date", yearEnd),
|
||||
supabase
|
||||
.from("employees_directory")
|
||||
.from("employees")
|
||||
.select("id", { count: "exact", head: true })
|
||||
.gte("exit_date", yearStart)
|
||||
.lte("exit_date", yearEnd),
|
||||
supabase.from("positions").select("id", { count: "exact", head: true }).eq("status", "open"),
|
||||
supabase.from("employees_directory").select("weekly_hours").in("status", ["Aktiv", "Karenz"]),
|
||||
supabase.from("employees").select("weekly_hours").in("status", ["Aktiv", "Karenz"]),
|
||||
supabase.from("divisions").select("id, name"),
|
||||
supabase.from("employees_directory").select("division_id").in("status", ["Aktiv", "Karenz"]),
|
||||
supabase.from("employees").select("division_id").in("status", ["Aktiv", "Karenz"]),
|
||||
supabase
|
||||
.from("employees_directory")
|
||||
.from("employees")
|
||||
.select("id, first_name, last_name, entry_date")
|
||||
.eq("status", "Geplant")
|
||||
.gte("entry_date", todayIso)
|
||||
.lte("entry_date", in60Iso),
|
||||
supabase
|
||||
.from("employees_directory")
|
||||
.from("employees")
|
||||
.select("id, first_name, last_name, exit_date")
|
||||
.not("exit_date", "is", null)
|
||||
.gte("exit_date", todayIso)
|
||||
.lte("exit_date", in60Iso),
|
||||
supabase
|
||||
.from("employees_directory")
|
||||
.from("employees")
|
||||
.select("id, first_name, last_name, karenz_return_date")
|
||||
.eq("status", "Karenz")
|
||||
.not("karenz_return_date", "is", null)
|
||||
@@ -148,7 +148,7 @@ export default async function DashboardPage() {
|
||||
|
||||
const historyEmployeeIds = Array.from(new Set((historyRes.data ?? []).map((h) => h.employee_id)));
|
||||
const historyEmployeesRes = historyEmployeeIds.length
|
||||
? await supabase.from("employees_directory").select("id, first_name, last_name").in("id", historyEmployeeIds)
|
||||
? await supabase.from("employees").select("id, first_name, last_name").in("id", historyEmployeeIds)
|
||||
: { data: [] as { id: string; first_name: string; last_name: string }[] };
|
||||
const employeeNameById = new Map((historyEmployeesRes.data ?? []).map((e) => [e.id, `${e.first_name} ${e.last_name}`]));
|
||||
|
||||
|
||||
@@ -12,9 +12,9 @@ export default async function PositionsPage() {
|
||||
supabase.from("divisions").select("*").order("name"),
|
||||
supabase.from("departments").select("*"),
|
||||
supabase.from("teams").select("*"),
|
||||
supabase.from("employees_directory").select("team_id, division_id, weekly_hours").in("status", ["Aktiv", "Karenz"]),
|
||||
supabase.from("employees").select("team_id, division_id, weekly_hours").in("status", ["Aktiv", "Karenz"]),
|
||||
supabase
|
||||
.from("employees_directory")
|
||||
.from("employees")
|
||||
.select("id, first_name, last_name, team_id, division_id, org_level, is_lead")
|
||||
.eq("status", "Aktiv")
|
||||
.or("is_lead.eq.true,org_level.eq.0"),
|
||||
|
||||
@@ -47,9 +47,9 @@ export default async function ReportsPage({ searchParams }: { searchParams: Prom
|
||||
};
|
||||
|
||||
let query = supabase
|
||||
.from("employees_directory")
|
||||
.from("employees")
|
||||
.select(
|
||||
"id, first_name, last_name, job_title, division_id, team_id, location_id, status, employment_type, contract_type, entry_date, exit_date, weekly_hours, monthly_salary_gross, source, paygrade, birth_date, gender"
|
||||
"id, first_name, last_name, job_title, division_id, team_id, location_id, status, employment_type, contract_type, entry_date, exit_date, weekly_hours, source, paygrade, birth_date, gender"
|
||||
);
|
||||
|
||||
if (params.division) query = query.eq("division_id", params.division);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { login } from "@/actions/auth";
|
||||
import { login, logout } from "@/actions/auth";
|
||||
|
||||
type LoginPageProps = {
|
||||
searchParams: Promise<{ error?: string }>;
|
||||
@@ -16,6 +16,11 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {
|
||||
{error && (
|
||||
<div role="alert" className="mt-4 rounded bg-danger-bg px-3 py-2 text-sm text-danger-text">
|
||||
{error}
|
||||
<form action={logout} className="mt-2">
|
||||
<button type="submit" className="text-xs font-semibold underline hover:no-underline">
|
||||
Abmelden und mit anderem Konto versuchen
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
24
app/api/cron/apply-pending-changes/route.ts
Normal file
24
app/api/cron/apply-pending-changes/route.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { NextResponse, type NextRequest } from "next/server";
|
||||
import { createAdminClient } from "@/lib/supabase/admin";
|
||||
|
||||
// Applies effective-dated changes (Versetzung/Beförderung/Karenz/Reorg/Daten
|
||||
// ändern with a future "Wirksam ab" date) once their date has arrived — see
|
||||
// apply_due_pending_changes() in supabase/migrations. Runs as a Vercel Cron
|
||||
// job (see vercel.json), not on behalf of any HR user, so it authenticates
|
||||
// via a shared secret rather than a Supabase session and uses the
|
||||
// service-role client (the one legitimate server-only use case for it).
|
||||
export async function GET(request: NextRequest) {
|
||||
const authHeader = request.headers.get("authorization");
|
||||
if (!process.env.CRON_SECRET || authHeader !== `Bearer ${process.env.CRON_SECRET}`) {
|
||||
return NextResponse.json({ error: "Nicht autorisiert." }, { status: 401 });
|
||||
}
|
||||
|
||||
const supabase = createAdminClient();
|
||||
const { data, error } = await supabase.rpc("apply_due_pending_changes");
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ applied: data });
|
||||
}
|
||||
Reference in New Issue
Block a user