uat-seed-script

This commit is contained in:
phaichayon
2026-06-30 10:48:15 +07:00
parent e4573ac0c9
commit e12ae120b0
31 changed files with 1791 additions and 314 deletions

View File

@@ -1,5 +1,9 @@
'use client';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
"use client";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
DropdownMenu,
DropdownMenuContent,
@@ -7,8 +11,8 @@ import {
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu';
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Sidebar,
SidebarContent,
@@ -22,18 +26,18 @@ import {
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarRail
} from '@/components/ui/sidebar';
import { UserAvatarProfile } from '@/components/user-avatar-profile';
import { navGroups } from '@/config/nav-config';
import { useMediaQuery } from '@/hooks/use-media-query';
import { useFilteredNavGroups } from '@/hooks/use-nav';
import Link from 'next/link';
import { signOut, useSession } from 'next-auth/react';
import { usePathname, useRouter } from 'next/navigation';
import * as React from 'react';
import { Icons } from '../icons';
import { OrgSwitcher } from '../org-switcher';
SidebarRail,
} from "@/components/ui/sidebar";
import { UserAvatarProfile } from "@/components/user-avatar-profile";
import { navGroups } from "@/config/nav-config";
import { useMediaQuery } from "@/hooks/use-media-query";
import { useFilteredNavGroups } from "@/hooks/use-nav";
import Link from "next/link";
import { signOut, useSession } from "next-auth/react";
import { usePathname, useRouter } from "next/navigation";
import * as React from "react";
import { Icons } from "../icons";
import { OrgSwitcher } from "../org-switcher";
export default function AppSidebar() {
const pathname = usePathname();
@@ -44,59 +48,67 @@ export default function AppSidebar() {
const user = session?.user ?? null;
const organization = session?.user?.activeOrganizationId;
const canManageOrganization =
session?.user?.systemRole === 'super_admin' ||
(!!organization && session?.user?.activeMembershipRole === 'admin');
session?.user?.systemRole === "super_admin" ||
(!!organization && session?.user?.activeMembershipRole === "admin");
React.useEffect(() => {
// Side effects based on sidebar state changes
}, [isOpen]);
React.useEffect(() => {
if (process.env.NODE_ENV !== 'development') {
if (process.env.NODE_ENV !== "development") {
return;
}
console.info('[sidebar-debug]', {
console.info("[sidebar-debug]", {
systemRole: session?.user?.systemRole ?? null,
activeOrganizationId: session?.user?.activeOrganizationId ?? null,
activeMembershipRole: session?.user?.activeMembershipRole ?? null,
activePermissions: session?.user?.activePermissions ?? [],
visibleItems: filteredGroups.flatMap((group) => group.items.map((item) => item.title))
visibleItems: filteredGroups.flatMap((group) =>
group.items.map((item) => item.title),
),
});
}, [
filteredGroups,
session?.user?.activeOrganizationId,
session?.user?.activePermissions,
session?.user?.activeMembershipRole,
session?.user?.systemRole
session?.user?.systemRole,
]);
return (
<Sidebar collapsible='icon'>
<SidebarHeader className='group-data-[collapsible=icon]:pt-4'>
<Sidebar collapsible="icon">
<SidebarHeader className="group-data-[collapsible=icon]:pt-4">
<OrgSwitcher />
</SidebarHeader>
<SidebarContent className='overflow-x-hidden'>
<SidebarContent className="overflow-x-hidden">
{filteredGroups.map((group) => (
<SidebarGroup key={group.label || 'ungrouped'} className='py-0'>
{group.label && <SidebarGroupLabel>{group.label}</SidebarGroupLabel>}
<SidebarGroup key={group.label || "ungrouped"} className="py-0">
{group.label && (
<SidebarGroupLabel>{group.label}</SidebarGroupLabel>
)}
<SidebarMenu>
{group.items.map((item) => {
const Icon = item.icon ? Icons[item.icon] : Icons.logo;
const itemKey = item.url || `${group.label ?? 'group'}-${item.title}`;
const itemKey =
item.url || `${group.label ?? "group"}-${item.title}`;
return item?.items && item?.items?.length > 0 ? (
<Collapsible
key={itemKey}
asChild
defaultOpen={item.isActive}
className='group/collapsible'
className="group/collapsible"
>
<SidebarMenuItem>
<CollapsibleTrigger asChild>
<SidebarMenuButton tooltip={item.title} isActive={pathname === item.url}>
<SidebarMenuButton
tooltip={item.title}
isActive={pathname === item.url}
>
{item.icon && <Icon />}
<span>{item.title}</span>
<Icons.chevronRight className='ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90' />
<Icons.chevronRight className="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
</SidebarMenuButton>
</CollapsibleTrigger>
<CollapsibleContent>
@@ -105,7 +117,10 @@ export default function AppSidebar() {
<SidebarMenuSubItem
key={subItem.url || `${itemKey}-${subItem.title}`}
>
<SidebarMenuSubButton asChild isActive={pathname === subItem.url}>
<SidebarMenuSubButton
asChild
isActive={pathname === subItem.url}
>
<Link href={subItem.url}>
<span>{subItem.title}</span>
</Link>
@@ -141,49 +156,57 @@ export default function AppSidebar() {
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
size='lg'
className='data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground'
size="lg"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
>
{user && (
<UserAvatarProfile className='h-8 w-8 rounded-lg' showInfo user={user} />
<UserAvatarProfile
className="h-8 w-8 rounded-lg"
showInfo
user={user}
/>
)}
<Icons.chevronsDown className='ml-auto size-4' />
<Icons.chevronsDown className="ml-auto size-4" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
className='w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg'
side='bottom'
align='end'
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
side="bottom"
align="end"
sideOffset={4}
>
<DropdownMenuLabel className='p-0 font-normal'>
<div className='px-1 py-1.5'>
<DropdownMenuLabel className="p-0 font-normal">
<div className="px-1 py-1.5">
{user && (
<UserAvatarProfile className='h-8 w-8 rounded-lg' showInfo user={user} />
<UserAvatarProfile
className="h-8 w-8 rounded-lg"
showInfo
user={user}
/>
)}
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem onClick={() => router.push('/dashboard/profile')}>
<Icons.account className='mr-2 h-4 w-4' />
<DropdownMenuItem
onClick={() => router.push("/dashboard/profile")}
>
<Icons.account className="mr-2 h-4 w-4" />
Profile
</DropdownMenuItem>
{canManageOrganization && (
<DropdownMenuItem onClick={() => router.push('/dashboard/billing')}>
<Icons.creditCard className='mr-2 h-4 w-4' />
Billing
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={() => router.push('/dashboard/notifications')}>
<Icons.notification className='mr-2 h-4 w-4' />
<DropdownMenuItem
onClick={() => router.push("/dashboard/notifications")}
>
<Icons.notification className="mr-2 h-4 w-4" />
Notifications
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => void signOut({ redirectTo: '/auth/sign-in' })}>
<Icons.logout className='mr-2 h-4 w-4' />
<DropdownMenuItem
onClick={() => void signOut({ redirectTo: "/auth/sign-in" })}
>
<Icons.logout className="mr-2 h-4 w-4" />
Sign out
</DropdownMenuItem>
</DropdownMenuContent>

View File

@@ -1,5 +1,5 @@
'use client';
import { Button } from '@/components/ui/button';
"use client";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
@@ -7,11 +7,11 @@ import {
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu';
import { UserAvatarProfile } from '@/components/user-avatar-profile';
import { signOut, useSession } from 'next-auth/react';
import { useRouter } from 'next/navigation';
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { UserAvatarProfile } from "@/components/user-avatar-profile";
import { signOut, useSession } from "next-auth/react";
import { useRouter } from "next/navigation";
export function UserNav() {
const { data: session } = useSession();
const user = session?.user;
@@ -21,28 +21,37 @@ export function UserNav() {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant='ghost' className='relative h-8 w-8 rounded-full'>
<Button variant="ghost" className="relative h-8 w-8 rounded-full">
<UserAvatarProfile user={user} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className='w-56' align='end' sideOffset={10} forceMount>
<DropdownMenuLabel className='font-normal'>
<div className='flex flex-col space-y-1'>
<p className='text-sm leading-none font-medium'>{user.name}</p>
<p className='text-muted-foreground text-xs leading-none'>{user.email}</p>
<DropdownMenuContent
className="w-56"
align="end"
sideOffset={10}
forceMount
>
<DropdownMenuLabel className="font-normal">
<div className="flex flex-col space-y-1">
<p className="text-sm leading-none font-medium">{user.name}</p>
<p className="text-muted-foreground text-xs leading-none">
{user.email}
</p>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem onClick={() => router.push('/dashboard/profile')}>
<DropdownMenuItem onClick={() => router.push("/dashboard/profile")}>
Profile
</DropdownMenuItem>
<DropdownMenuItem>Billing</DropdownMenuItem>
<DropdownMenuItem>Settings</DropdownMenuItem>
<DropdownMenuItem>New Team</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => void signOut({ redirectTo: '/auth/sign-in' })}>
<DropdownMenuItem
onClick={() => void signOut({ redirectTo: "/auth/sign-in" })}
>
Sign out
</DropdownMenuItem>
</DropdownMenuContent>

View File

@@ -345,6 +345,7 @@ type UserContext = {
admin: UserMembershipRow;
marketing: UserMembershipRow;
sales: UserMembershipRow[];
salesByProductType: Partial<Record<string, UserMembershipRow>>;
salesManager: UserMembershipRow;
departmentManager: UserMembershipRow;
topManager: UserMembershipRow;
@@ -352,33 +353,41 @@ type UserContext = {
const SEED_TAG = "crm-uat-seed-v1";
const PERIOD = "2606";
const UAT_EMAILS = {
marketing: "mk.uat@alla.local",
salesCrane: "sales.crane.uat@alla.local",
salesDockDoor: "sales.dockdoor.uat@alla.local",
salesManager: "mgr.sales.uat@alla.local",
topManager: "ceo.uat@alla.local",
admin: "admin.uat@alla.local",
} as const;
const STORIES: StoryConfig[] = [
{
slug: "website-warehouse-crane-win",
title: "Lead received from website and won after approval",
title: "Siam Manufacturing crane project waiting final CEO approval",
customerName: "บริษัท บางกอก ฟู้ด อินดัสทรี จำกัด",
province: "Bangkok",
district: "Min Buri",
province: "Chachoengsao",
district: "Bang Pakong",
branchCode: "head_office",
customerGroupCode: "industrial",
customerSubGroupCode: "factory",
productTypeCode: "crane",
quotationTypeCode: "crane",
priorityCode: "high",
leadChannelCode: "website",
leadChannelCode: "referral",
awarenessCode: "google_search_website",
leadStatusCode: "assigned",
leadFollowupStatusCode: "waiting_po",
leadFollowupStatusCode: "drawing_followup",
opportunityStatusCode: "negotiation",
chancePercent: 80,
estimatedValue: 12400000,
projectName: "Warehouse Crane Installation Phase 2",
projectLocation: "Bangkok Food Industrial Estate, Min Buri",
chancePercent: 78,
estimatedValue: 8750000,
projectName: "Overhead Crane Installation Phase 1",
projectLocation: "Toyota Gateway Plant",
leadDescription:
"Customer asked for top-running overhead crane to support raw material warehouse expansion.",
quotePlan: "accepted",
opportunityOutcome: "won",
"Marketing qualified the crane project and handed it to the crane sales queue for quotation approval.",
quotePlan: "pending_step_2",
opportunityOutcome: "open",
multiParties: [
{
roleCode: "contractor",
@@ -392,57 +401,57 @@ const STORIES: StoryConfig[] = [
},
{
slug: "facebook-lost-price",
title: "Lead lost to competitor because price too high",
title: "Demo lost customer with no quotation because budget not approved",
customerName: "บริษัท ไทย ออโต้ เพรส จำกัด",
province: "Samut Prakan",
district: "Bang Phli",
branchCode: "factory",
province: "Bangkok",
district: "Lat Krabang",
branchCode: "service",
customerGroupCode: "industrial",
customerSubGroupCode: "factory",
productTypeCode: "crane",
quotationTypeCode: "crane",
customerSubGroupCode: "warehouse",
productTypeCode: "dockdoor",
quotationTypeCode: "dockdoor",
priorityCode: "normal",
leadChannelCode: "facebook",
awarenessCode: "social_media",
leadStatusCode: "closed_lost",
leadFollowupStatusCode: "quotation_preparation",
opportunityStatusCode: "quotation_created",
chancePercent: 45,
estimatedValue: 5800000,
projectName: "Stamping Line Hoist Replacement",
projectLocation: "Bang Phli Industrial Estate",
leadChannelCode: "website",
awarenessCode: "google_search_website",
leadStatusCode: "cancel",
leadFollowupStatusCode: "budget_pending",
opportunityStatusCode: "qualification",
chancePercent: 20,
estimatedValue: 1900000,
projectName: "Budget Hold Demo Project",
projectLocation: "Lat Krabang Logistics Zone",
leadDescription:
"Customer compared 5 ton monorail hoist replacement between ALLA and Konecranes.",
competitor: "Konecranes",
quotePlan: "sent_follow_up",
opportunityOutcome: "lost",
lostReasonCode: "price_too_high",
"Opportunity was stopped before quotation because customer budget was not approved and competitor was still under consideration.",
competitor: "Competitor Selected",
quotePlan: "cancelled",
opportunityOutcome: "no_quotation",
noQuotationReasonCode: "budget_not_ready",
},
{
slug: "revision-r02-approved",
title: "Customer requested revision and approved revision R02",
title: "Eastern Logistics dock door project in draft and follow-up stage",
customerName: "บริษัท กรีน โลจิสติกส์ พาร์ค จำกัด",
province: "Pathum Thani",
district: "Khlong Luang",
province: "Chonburi",
district: "Si Racha",
branchCode: "service",
customerGroupCode: "industrial",
customerSubGroupCode: "logistics",
customerSubGroupCode: "warehouse",
productTypeCode: "dockdoor",
quotationTypeCode: "dockdoor",
priorityCode: "high",
leadChannelCode: "referral",
awarenessCode: "word_of_mouth",
leadStatusCode: "assigned",
leadFollowupStatusCode: "waiting_po",
opportunityStatusCode: "negotiation",
chancePercent: 70,
estimatedValue: 9300000,
projectName: "Cold Chain Dock Leveller Upgrade",
projectLocation: "Khlong Luang Logistics Campus",
leadFollowupStatusCode: "quotation_preparation",
opportunityStatusCode: "proposal_preparation",
chancePercent: 61,
estimatedValue: 4600000,
projectName: "Loading Dock Expansion",
projectLocation: "WHA Warehouse",
leadDescription:
"Customer requested revision after consultant changed dock layout and bumper specification.",
quotePlan: "rejected_then_revised",
opportunityOutcome: "won",
"Dock door salesperson is still following up layout confirmation before final quotation submission.",
quotePlan: "sent_follow_up",
opportunityOutcome: "open",
multiParties: [
{
roleCode: "consultant",
@@ -882,6 +891,30 @@ function buildDocumentCode(prefix: string, number: number) {
return `${prefix}${PERIOD}-${String(number).padStart(3, "0")}`;
}
function buildQuotationCode(productTypeCode: string, number: number) {
const prefixByProductType: Record<string, string> = {
crane: "CR",
dockdoor: "DK",
solarcell: "SOL",
service: "SV",
spare_part: "SP",
};
return buildDocumentCode(prefixByProductType[productTypeCode] ?? "QT", number);
}
function pickSalesUser(
users: UserContext,
productTypeCode: string | undefined,
index: number
) {
if (productTypeCode && users.salesByProductType[productTypeCode]) {
return users.salesByProductType[productTypeCode]!;
}
return choose(users.sales, index);
}
function seededNote(scope: string) {
return `[${SEED_TAG}:${scope}]`;
}
@@ -1027,21 +1060,48 @@ function buildUserContext(rows: UserMembershipRow[]): UserContext {
}
const first = rows[0];
const byRole = (role: string) =>
rows.find((row) => row.businessRole === role) ?? null;
const salesRows = rows.filter((row) => row.businessRole === "sales");
const byRole = (role: string) => rows.find((row) => row.businessRole === role) ?? null;
const byEmail = (email: string) =>
rows.find((row) => row.email.toLowerCase() === email.toLowerCase()) ?? null;
const fallbackSalesRows = rows.filter((row) => row.businessRole === "sales");
const preferredSalesRows = [
byEmail(UAT_EMAILS.salesCrane),
byEmail(UAT_EMAILS.salesDockDoor),
].filter((row): row is UserMembershipRow => row !== null);
const salesRows = preferredSalesRows.length
? preferredSalesRows
: fallbackSalesRows.length
? fallbackSalesRows
: [rows.find((row) => row.membershipRole === "admin") ?? first];
const admin =
byEmail(UAT_EMAILS.admin) ??
rows.find((row) => row.membershipRole === "admin") ??
rows.find((row) => row.systemRole === "super_admin") ??
first;
return {
admin,
marketing: byRole("sales_support") ?? admin,
sales: salesRows.length ? salesRows : [admin],
salesManager: byRole("sales_manager") ?? admin,
departmentManager: byRole("department_manager") ?? admin,
topManager: byRole("top_manager") ?? admin,
marketing:
byEmail(UAT_EMAILS.marketing) ??
byRole("marketing") ??
byRole("sales_support") ??
admin,
sales: salesRows,
salesByProductType: {
crane: byEmail(UAT_EMAILS.salesCrane) ?? preferredSalesRows[0] ?? salesRows[0],
dockdoor:
byEmail(UAT_EMAILS.salesDockDoor) ??
preferredSalesRows.at(-1) ??
salesRows.at(-1) ??
salesRows[0],
},
salesManager: byEmail(UAT_EMAILS.salesManager) ?? byRole("sales_manager") ?? admin,
departmentManager:
byEmail(UAT_EMAILS.salesManager) ??
byRole("department_manager") ??
byRole("sales_manager") ??
admin,
topManager: byEmail(UAT_EMAILS.topManager) ?? byRole("top_manager") ?? admin,
};
}
@@ -1395,7 +1455,7 @@ function buildLeadAndOpportunityData(
}
const contact = primaryContactByCustomerId.get(customer.id) ?? null;
const createdAt = daysAgo(88 - storyIndex * 4);
const assignedUser = choose(users.sales, storyIndex);
const assignedUser = pickSalesUser(users, story.productTypeCode, storyIndex);
const assignedAt = addDays(createdAt, 4);
const statusId = getOptionId(
optionMap,
@@ -2004,7 +2064,7 @@ function buildQuotationData(
const customer = storyCustomerMap.get(story.slug);
if (!opportunity || !customer) continue;
const contact = primaryContactByCustomerId.get(customer.id) ?? null;
const salesperson = choose(users.sales, storyIndex);
const salesperson = pickSalesUser(users, story.productTypeCode, storyIndex);
const baseDate = addDays(opportunity.createdAt, 10);
const planRevisionCount =
story.quotePlan === "returned_then_revised"
@@ -2067,7 +2127,7 @@ function buildQuotationData(
quotations.push({
id: quotationId,
code: buildDocumentCode("QT", quotationSequence++),
code: buildQuotationCode(story.productTypeCode, quotationSequence++),
opportunityId: opportunity.id,
customerId: customer.id,
contactId: contact?.id ?? null,
@@ -2405,7 +2465,10 @@ function buildQuotationData(
const quotationId = crypto.randomUUID();
quotations.push({
id: quotationId,
code: buildDocumentCode("QT", quotationSequence++),
code: buildQuotationCode(
choose(["crane", "dockdoor", "solarcell"], quotations.length),
quotationSequence++
),
opportunityId: null,
customerId: customer.id,
contactId: contact?.id ?? null,

View File

@@ -0,0 +1,129 @@
import { getDefaultPermissions } from '../../lib/auth/rbac.ts';
import { createSqlClient, deterministicId, type SqlClient } from '../../../scripts/lib/seed-utils.ts';
type UserRow = {
id: string;
email: string;
};
const DEFAULT_ORGANIZATION = {
id: deterministicId('organization:alla-demo'),
name: 'ALLA Demo Organization',
slug: 'alla-demo',
membershipId: deterministicId('membership:alla-demo:super-admin')
};
async function resolveSuperAdmin(sql: SqlClient): Promise<UserRow> {
const email = process.env.SUPER_ADMIN_EMAIL?.trim().toLowerCase();
if (!email) {
throw new Error('SUPER_ADMIN_EMAIL is required before running system organization seed.');
}
const [user] = await sql<UserRow[]>`
select id, email
from users
where email = ${email}
limit 1
`;
if (!user) {
throw new Error('Super admin user not found. Run seed-super-admin first.');
}
return user;
}
async function ensureOrganization(sql: SqlClient, superAdmin: UserRow) {
await sql`
insert into organizations (
id,
name,
slug,
plan,
created_by
) values (
${DEFAULT_ORGANIZATION.id},
${DEFAULT_ORGANIZATION.name},
${DEFAULT_ORGANIZATION.slug},
${'enterprise'},
${superAdmin.id}
)
on conflict (slug) do update
set
name = excluded.name,
plan = excluded.plan,
updated_at = now()
`;
const membershipPermissions = getDefaultPermissions('admin', 'crm_admin');
const [existingMembership] = await sql<{ id: string }[]>`
select id
from memberships
where user_id = ${superAdmin.id}
and organization_id = ${DEFAULT_ORGANIZATION.id}
limit 1
`;
if (existingMembership) {
await sql`
update memberships
set
role = ${'admin'},
business_role = ${'crm_admin'},
permissions = ${sql.array(membershipPermissions)},
updated_at = now()
where id = ${existingMembership.id}
`;
} else {
await sql`
insert into memberships (
id,
user_id,
organization_id,
role,
business_role,
permissions,
branch_scope_ids,
product_type_scope_ids
) values (
${DEFAULT_ORGANIZATION.membershipId},
${superAdmin.id},
${DEFAULT_ORGANIZATION.id},
${'admin'},
${'crm_admin'},
${sql.array(membershipPermissions)},
${sql.array([])},
${sql.array([])}
)
`;
}
await sql`
update users
set
active_organization_id = ${DEFAULT_ORGANIZATION.id},
updated_at = now()
where id = ${superAdmin.id}
`;
}
async function main() {
const sql = createSqlClient();
try {
const superAdmin = await resolveSuperAdmin(sql);
await ensureOrganization(sql, superAdmin);
console.log(`[seed:system-org] Ready ${DEFAULT_ORGANIZATION.name}`);
} finally {
await sql.end();
}
}
main().catch((error) => {
console.error(
'[seed:system-org] Failed:',
error instanceof Error ? error.message : String(error)
);
process.exitCode = 1;
});

View File

@@ -1,5 +1,11 @@
import { spawnSync } from 'node:child_process';
const NODE_TS_RUNTIME_ARGS = [
'--disable-warning=ExperimentalWarning',
'--disable-warning=MODULE_TYPELESS_PACKAGE_JSON',
'--experimental-strip-types'
];
function run(label: string, args: string[]) {
const result = spawnSync(process.execPath, args, {
stdio: 'inherit',
@@ -13,7 +19,12 @@ function run(label: string, args: string[]) {
function main() {
run('super admin seed', ['scripts/seed-super-admin.js']);
run('foundation seed', ['--experimental-strip-types', 'src/db/seeds/foundation.seed.ts']);
run('system organization seed', [
...NODE_TS_RUNTIME_ARGS,
'src/db/seeds/system-organization.seed.ts'
]);
run('foundation seed', [...NODE_TS_RUNTIME_ARGS, 'src/db/seeds/foundation.seed.ts']);
run('pdf template seed', [...NODE_TS_RUNTIME_ARGS, 'scripts/reseed-pdf-template-version.ts']);
}
main();

View File

@@ -0,0 +1,431 @@
import bcrypt from 'bcryptjs';
import { getDefaultPermissions, getDefaultRoleProfiles } from '../../lib/auth/rbac.ts';
import { createSqlClient, deterministicId, type SqlClient } from '../../../scripts/lib/seed-utils.ts';
type OrganizationRow = {
id: string;
name: string;
createdBy: string;
};
type OptionRow = {
id: string;
category: string;
code: string;
};
type RoleProfileRow = {
id: string;
code: string;
};
type UatRoleAssignmentSeed = {
roleCode: string;
isPrimary: boolean;
branchScopeMode: 'all' | 'selected' | 'inherit';
branchCodes?: string[];
productTypeScopeMode: 'all' | 'selected' | 'inherit';
productTypeCodes?: string[];
};
type UatUserSeed = {
key: string;
name: string;
email: string;
password: string;
systemRole: 'super_admin' | 'user';
membershipRole: 'admin' | 'user';
businessRole:
| 'marketing'
| 'sales'
| 'sales_support'
| 'sales_manager'
| 'department_manager'
| 'top_manager'
| 'crm_admin';
roleAssignments: UatRoleAssignmentSeed[];
};
const UAT_PASSWORD = 'UatDemo123!';
const UAT_USERS: UatUserSeed[] = [
{
key: 'mk',
name: 'MK UAT',
email: 'mk.uat@alla.local',
password: UAT_PASSWORD,
systemRole: 'user',
membershipRole: 'user',
businessRole: 'marketing',
roleAssignments: [
{
roleCode: 'marketing',
isPrimary: true,
branchScopeMode: 'all',
productTypeScopeMode: 'all'
}
]
},
{
key: 'sales-crane',
name: 'Sales Crane UAT',
email: 'sales.crane.uat@alla.local',
password: UAT_PASSWORD,
systemRole: 'user',
membershipRole: 'user',
businessRole: 'sales',
roleAssignments: [
{
roleCode: 'sales',
isPrimary: true,
branchScopeMode: 'all',
productTypeScopeMode: 'selected',
productTypeCodes: ['crane']
}
]
},
{
key: 'sales-dockdoor',
name: 'Sales Dock Door UAT',
email: 'sales.dockdoor.uat@alla.local',
password: UAT_PASSWORD,
systemRole: 'user',
membershipRole: 'user',
businessRole: 'sales',
roleAssignments: [
{
roleCode: 'sales',
isPrimary: true,
branchScopeMode: 'all',
productTypeScopeMode: 'selected',
productTypeCodes: ['dockdoor']
}
]
},
{
key: 'mgr-sales',
name: 'Sales Manager UAT',
email: 'mgr.sales.uat@alla.local',
password: UAT_PASSWORD,
systemRole: 'user',
membershipRole: 'user',
businessRole: 'sales_manager',
roleAssignments: [
{
roleCode: 'sales_manager',
isPrimary: true,
branchScopeMode: 'all',
productTypeScopeMode: 'all'
},
{
roleCode: 'department_manager',
isPrimary: false,
branchScopeMode: 'all',
productTypeScopeMode: 'all'
}
]
},
{
key: 'ceo',
name: 'CEO UAT',
email: 'ceo.uat@alla.local',
password: UAT_PASSWORD,
systemRole: 'user',
membershipRole: 'user',
businessRole: 'top_manager',
roleAssignments: [
{
roleCode: 'top_manager',
isPrimary: true,
branchScopeMode: 'all',
productTypeScopeMode: 'all'
}
]
},
{
key: 'admin',
name: 'Admin UAT',
email: 'admin.uat@alla.local',
password: UAT_PASSWORD,
systemRole: 'user',
membershipRole: 'admin',
businessRole: 'crm_admin',
roleAssignments: [
{
roleCode: 'crm_admin',
isPrimary: true,
branchScopeMode: 'all',
productTypeScopeMode: 'all'
}
]
}
];
async function ensureRoleProfiles(
sql: SqlClient,
organization: OrganizationRow
): Promise<Map<string, RoleProfileRow>> {
const defaultProfiles = getDefaultRoleProfiles();
for (const profile of defaultProfiles) {
await sql`
insert into crm_role_profiles (
id,
organization_id,
code,
name,
description,
permissions,
branch_scope_mode,
product_scope_mode,
ownership_scope,
approval_authority,
is_system,
is_active,
created_by,
updated_by
) values (
${deterministicId(`crm-role-profile:${organization.id}:${profile.code}`)},
${organization.id},
${profile.code},
${profile.name},
${profile.description},
${sql.array(profile.permissions)},
${profile.branchScopeMode},
${profile.productScopeMode},
${profile.ownershipScope},
${profile.approvalAuthority},
${profile.isSystem},
${true},
${organization.createdBy},
${organization.createdBy}
)
on conflict (organization_id, code) do update
set
name = excluded.name,
description = excluded.description,
permissions = excluded.permissions,
branch_scope_mode = excluded.branch_scope_mode,
product_scope_mode = excluded.product_scope_mode,
ownership_scope = excluded.ownership_scope,
approval_authority = excluded.approval_authority,
is_system = excluded.is_system,
is_active = excluded.is_active,
updated_by = excluded.updated_by,
deleted_at = null,
updated_at = now()
`;
}
const rows = await sql<RoleProfileRow[]>`
select id, code
from crm_role_profiles
where organization_id = ${organization.id}
and deleted_at is null
`;
return new Map(rows.map((row) => [row.code, row]));
}
async function loadOptions(sql: SqlClient, organizationId: string): Promise<Map<string, OptionRow>> {
const rows = await sql<OptionRow[]>`
select id, category, code
from ms_options
where organization_id = ${organizationId}
and deleted_at is null
and is_active = true
`;
return new Map(rows.map((row) => [`${row.category}:${row.code}`, row]));
}
function resolveScopeIds(
optionMap: Map<string, OptionRow>,
category: string,
codes: string[] | undefined
): string[] {
if (!codes?.length) {
return [];
}
return codes.map((code) => {
const row = optionMap.get(`${category}:${code}`);
if (!row) {
throw new Error(`Missing ${category} option for code ${code}`);
}
return row.id;
});
}
async function upsertUatUser(
sql: SqlClient,
organization: OrganizationRow,
optionMap: Map<string, OptionRow>,
roleProfileMap: Map<string, RoleProfileRow>,
seed: UatUserSeed
) {
const passwordHash = await bcrypt.hash(seed.password, 10);
const [userRow] = await sql<{ id: string }[]>`
insert into users (
id,
name,
email,
password_hash,
system_role,
active_organization_id
) values (
${deterministicId(`uat-user:${seed.email}`)},
${seed.name},
${seed.email},
${passwordHash},
${seed.systemRole},
${organization.id}
)
on conflict (email) do update
set
name = excluded.name,
password_hash = excluded.password_hash,
system_role = excluded.system_role,
active_organization_id = excluded.active_organization_id,
updated_at = now()
returning id
`;
const membershipPermissions = getDefaultPermissions(seed.membershipRole, seed.businessRole);
const existingMembership =
(
await sql<{ id: string }[]>`
select id
from memberships
where user_id = ${userRow.id}
and organization_id = ${organization.id}
limit 1
`
)[0] ?? null;
if (existingMembership) {
await sql`
update memberships
set
role = ${seed.membershipRole},
business_role = ${seed.businessRole},
permissions = ${sql.array(membershipPermissions)},
branch_scope_ids = ${sql.array([])},
product_type_scope_ids = ${sql.array([])},
updated_at = now()
where id = ${existingMembership.id}
`;
} else {
await sql`
insert into memberships (
id,
user_id,
organization_id,
role,
business_role,
permissions,
branch_scope_ids,
product_type_scope_ids
) values (
${deterministicId(`membership:${organization.id}:${seed.email}`)},
${userRow.id},
${organization.id},
${seed.membershipRole},
${seed.businessRole},
${sql.array(membershipPermissions)},
${sql.array([])},
${sql.array([])}
)
`;
}
await sql`
delete from crm_user_role_assignments
where organization_id = ${organization.id}
and user_id = ${userRow.id}
`;
for (const assignment of seed.roleAssignments) {
const roleProfile = roleProfileMap.get(assignment.roleCode);
if (!roleProfile) {
throw new Error(`Missing CRM role profile ${assignment.roleCode}`);
}
await sql`
insert into crm_user_role_assignments (
id,
organization_id,
user_id,
role_profile_id,
branch_scope_mode,
branch_scope_ids,
product_type_scope_mode,
product_type_scope_ids,
is_primary,
is_active,
assigned_by,
assigned_at,
created_at,
updated_at,
deleted_at
) values (
${deterministicId(`crm-role-assignment:${organization.id}:${seed.email}:${assignment.roleCode}`)},
${organization.id},
${userRow.id},
${roleProfile.id},
${assignment.branchScopeMode},
${sql.array(resolveScopeIds(optionMap, 'crm_branch', assignment.branchCodes))},
${assignment.productTypeScopeMode},
${sql.array(resolveScopeIds(optionMap, 'crm_product_type', assignment.productTypeCodes))},
${assignment.isPrimary},
${true},
${organization.createdBy},
${new Date()},
${new Date()},
${new Date()},
${null}
)
`;
}
}
async function seedOrganization(sql: SqlClient, organization: OrganizationRow) {
const optionMap = await loadOptions(sql, organization.id);
const roleProfileMap = await ensureRoleProfiles(sql, organization);
for (const seed of UAT_USERS) {
await upsertUatUser(sql, organization, optionMap, roleProfileMap, seed);
}
console.log(`[seed:uat-users] ${organization.name}: ${UAT_USERS.length} UAT users ready`);
}
async function main() {
const sql = createSqlClient();
try {
const organizations = await sql<OrganizationRow[]>`
select id, name, created_by as "createdBy"
from organizations
order by name asc
`;
if (organizations.length === 0) {
throw new Error('No organizations found. Run seed:system first.');
}
for (const organization of organizations) {
await seedOrganization(sql, organization);
}
} finally {
await sql.end();
}
}
main().catch((error) => {
console.error('[seed:uat-users] Failed:', error instanceof Error ? error.message : String(error));
process.exitCode = 1;
});

View File

@@ -1,17 +1,32 @@
import { spawnSync } from 'node:child_process';
const NODE_TS_RUNTIME_ARGS = [
'--disable-warning=ExperimentalWarning',
'--disable-warning=MODULE_TYPELESS_PACKAGE_JSON',
'--experimental-strip-types'
];
function main() {
const result = spawnSync(
process.execPath,
['--experimental-strip-types', 'src/db/seeds/crm-uat.seed.ts'],
const steps: Array<{ label: string; args: string[] }> = [
{
label: 'uat users seed',
args: [...NODE_TS_RUNTIME_ARGS, 'src/db/seeds/uat-users.seed.ts']
},
{
label: 'crm uat data seed',
args: [...NODE_TS_RUNTIME_ARGS, 'src/db/seeds/crm-uat.seed.ts']
}
];
for (const step of steps) {
const result = spawnSync(process.execPath, step.args, {
stdio: 'inherit',
env: process.env
}
);
});
if (result.status !== 0) {
throw new Error(`[seed:uat] failed with exit code ${result.status ?? 1}`);
if (result.status !== 0) {
throw new Error(`[seed:uat] ${step.label} failed with exit code ${result.status ?? 1}`);
}
}
}

View File

@@ -18,7 +18,7 @@ const signInSchema = z.object({
export default function SignInViewPage() {
const searchParams = useSearchParams();
const callbackUrl = searchParams.get("callbackUrl") ?? "/dashboard";
const callbackUrl = searchParams.get("callbackUrl") ?? "/dashboard/crm";
const [isPending, startTransition] = useTransition();
const form = useAppForm({

View File

@@ -610,7 +610,7 @@ async function canAccessCustomerRow(
accessContext,
));
if (accessContext.ownershipScope === "monitor") {
if (accessContext.ownershipScope === 'monitor') {
return opportunityCustomerIds.has(row.id);
}

View File

@@ -1,29 +1,39 @@
import { auth } from '@/auth';
import { NextResponse } from 'next/server';
import { auth } from "@/auth";
import { NextResponse } from "next/server";
const protectedApiPrefixes = ['/api/products', '/api/organizations', '/api/users'];
const superAdminOnlyRoutes = ['/dashboard/workspaces'];
const organizationAdminRoutes = ['/dashboard/users', '/dashboard/workspaces/team'];
const protectedApiPrefixes = [
"/api/products",
"/api/organizations",
"/api/users",
];
const superAdminOnlyRoutes = ["/dashboard/workspaces"];
const organizationAdminRoutes = [
"/dashboard/users",
"/dashboard/workspaces/team",
];
export default auth((req) => {
const pathname = req.nextUrl.pathname;
const isDashboardRoute = pathname.startsWith('/dashboard');
const isProtectedApiRoute = protectedApiPrefixes.some((prefix) => pathname.startsWith(prefix));
const isDashboardRoute = pathname.startsWith("/dashboard/crm");
const isProtectedApiRoute = protectedApiPrefixes.some((prefix) =>
pathname.startsWith(prefix),
);
const user = req.auth?.user;
const isSignedIn = !!user;
const isSuperAdmin = user?.systemRole === 'super_admin';
const isSuperAdmin = user?.systemRole === "super_admin";
const isOrganizationAdmin =
isSuperAdmin ||
(user?.activeOrganizationId &&
(user.activeMembershipRole === 'admin' || user.activePermissions?.includes('users:manage')));
(user.activeMembershipRole === "admin" ||
user.activePermissions?.includes("users:manage")));
if (!isSignedIn && (isDashboardRoute || isProtectedApiRoute)) {
if (isProtectedApiRoute) {
return NextResponse.json({ message: 'Unauthorized' }, { status: 401 });
return NextResponse.json({ message: "Unauthorized" }, { status: 401 });
}
const signInUrl = new URL('/auth/sign-in', req.nextUrl.origin);
signInUrl.searchParams.set('callbackUrl', pathname);
const signInUrl = new URL("/auth/sign-in", req.nextUrl.origin);
signInUrl.searchParams.set("callbackUrl", pathname);
return NextResponse.redirect(signInUrl);
}
@@ -32,7 +42,7 @@ export default auth((req) => {
superAdminOnlyRoutes.some((prefix) => pathname.startsWith(prefix)) &&
!isSuperAdmin
) {
return NextResponse.redirect(new URL('/dashboard', req.nextUrl.origin));
return NextResponse.redirect(new URL("/dashboard", req.nextUrl.origin));
}
if (
@@ -40,7 +50,7 @@ export default auth((req) => {
organizationAdminRoutes.some((prefix) => pathname.startsWith(prefix)) &&
!isOrganizationAdmin
) {
return NextResponse.redirect(new URL('/dashboard', req.nextUrl.origin));
return NextResponse.redirect(new URL("/dashboard", req.nextUrl.origin));
}
return NextResponse.next();
@@ -48,7 +58,7 @@ export default auth((req) => {
export const config = {
matcher: [
'/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)',
'/(api|trpc)(.*)'
]
"/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)",
"/(api|trpc)(.*)",
],
};