diff --git a/drizzle/0000_dapper_klaw.sql b/drizzle/0000_classy_unicorn.sql similarity index 100% rename from drizzle/0000_dapper_klaw.sql rename to drizzle/0000_classy_unicorn.sql diff --git a/drizzle/meta/0000_snapshot.json b/drizzle/meta/0000_snapshot.json index 9d32ea6..b1d44bb 100644 --- a/drizzle/meta/0000_snapshot.json +++ b/drizzle/meta/0000_snapshot.json @@ -1,5 +1,5 @@ { - "id": "cdc9d738-9191-4a58-96bc-66ac8bdb7d7f", + "id": "6c217ceb-57e5-410e-9132-610c819272f2", "prevId": "00000000-0000-0000-0000-000000000000", "version": "7", "dialect": "postgresql", diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 94192e4..82baf40 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -5,8 +5,8 @@ { "idx": 0, "version": "7", - "when": 1783059567682, - "tag": "0000_dapper_klaw", + "when": 1783326039269, + "tag": "0000_classy_unicorn", "breakpoints": true } ] diff --git a/setup/ALLA-ONVALLA/approval-matrix.csv b/setup/ALLA-ONVALLA/approval-matrix.csv new file mode 100644 index 0000000..8c8030c --- /dev/null +++ b/setup/ALLA-ONVALLA/approval-matrix.csv @@ -0,0 +1,2 @@ +organization_code,entity_type,workflow_code,branch_code,product_type_code,min_amount,max_amount,currency,priority,is_default,is_active,description + diff --git a/setup/ALLA-ONVALLA/approval-steps.csv b/setup/ALLA-ONVALLA/approval-steps.csv new file mode 100644 index 0000000..61063e8 --- /dev/null +++ b/setup/ALLA-ONVALLA/approval-steps.csv @@ -0,0 +1 @@ +organization_code,workflow_code,step_number,role_code,role_name,approval_mode,is_required,sla_hours,calendar_mode,timeout_action diff --git a/setup/ALLA-ONVALLA/approval-workflows.csv b/setup/ALLA-ONVALLA/approval-workflows.csv new file mode 100644 index 0000000..407555c --- /dev/null +++ b/setup/ALLA-ONVALLA/approval-workflows.csv @@ -0,0 +1 @@ +organization_code,workflow_code,workflow_name,entity_type,description,is_system,is_active diff --git a/setup/ALLA-ONVALLA/branches.csv b/setup/ALLA-ONVALLA/branches.csv new file mode 100644 index 0000000..ba53c3f --- /dev/null +++ b/setup/ALLA-ONVALLA/branches.csv @@ -0,0 +1,3 @@ +organization_code,branch_code,branch_name,prefix,address,tax_id,phone,email,sort_order,is_active +ALLA,head_office,Head Office,AL,"933, 935, 937,939 ซอยอ่อนนุช 46 ถนนอ่อนนุช แขวงอ่อนนุช เขตสวนหลวง กรุงเทพมหานคร 10250",0107558000393,023220776,head.office@example.com,1,true +ONVALLA,head_office,Head Office,ON,เลขที่ 50 หมู่ 20 ตำบลลำลูกกา อำเภอลำลูกกา จังหวัดปทุมธานี 12150,0107558000393,021935380,eastern@example.com,2,true diff --git a/setup/ALLA-ONVALLA/crm-role-assignments.csv b/setup/ALLA-ONVALLA/crm-role-assignments.csv new file mode 100644 index 0000000..fbce142 --- /dev/null +++ b/setup/ALLA-ONVALLA/crm-role-assignments.csv @@ -0,0 +1,2 @@ +organization_code,user_email,crm_role_code,is_primary,branch_scope_mode,branch_codes,product_type_scope_mode,product_type_codes,is_active + diff --git a/setup/ALLA-ONVALLA/document-sequences.csv b/setup/ALLA-ONVALLA/document-sequences.csv new file mode 100644 index 0000000..c72de7e --- /dev/null +++ b/setup/ALLA-ONVALLA/document-sequences.csv @@ -0,0 +1,6 @@ +organization_code,branch_code,product_type_code,document_type,prefix,period,current_number,padding_length,format,reset_policy,is_active +ALLA,head_office,crane,customer,CUS,yearly,0,4,{prefix}-{yyyy}-{running},period,true +ALLA,head_office,crane,lead,LD,yearly,0,4,{prefix}-{yyyy}-{running},period,true +ALLA,head_office,crane,opportunity,OP,yearly,0,4,{prefix}-{yyyy}-{running},period,true +ALLA,head_office,crane,quotation,QT-AL-CR,monthly,0,4,{prefix}-{yy}{MM}-{running},period,true +ONVALLA,head_office,crane,quotation,QT-ON-CR,monthly,0,4,{prefix}-{yy}{MM}-{running},period,true diff --git a/setup/ALLA-ONVALLA/master-options.csv b/setup/ALLA-ONVALLA/master-options.csv new file mode 100644 index 0000000..b6b8a74 --- /dev/null +++ b/setup/ALLA-ONVALLA/master-options.csv @@ -0,0 +1,133 @@ +organization_code,category,code,label,value,parent_category,parent_code,sort_order,is_active,metadata_json +ALLA,crm_customer_group,construction,Construction,,,,1,true,{} +ALLA,crm_customer_group,industrial,Industrial,,,,2,true,{} + +ALLA,crm_customer_sub_group,factory,Factory,,crm_customer_group,industrial,1,true,{} +ALLA,crm_customer_sub_group,warehouse,Warehouse,,crm_customer_group,industrial,2,true,{} + +ALLA,crm_customer_status,active,Active,,,,1,true,{} +ALLA,crm_customer_status,inactive,Inactive,,,,2,true,{} +ALLA,crm_customer_status,prospect,Prospect,,,,3,true,{} + +ALLA,crm_customer_type,end_customer,End Customer,,,,1,true,{} +ALLA,crm_customer_type,contractor,Contractor,,,,2,true,{} +ALLA,crm_customer_type,consultant,Consultant,,,,3,true,{} +ALLA,crm_customer_type,developer,Developer,,,,4,true,{} + +ALLA,crm_lead_channel,call_in,Call in,,,,1,true,{} +ALLA,crm_lead_channel,ecommerce,E-commerce,,,,2,true,{} +ALLA,crm_lead_channel,email,E-mail,,,,3,true,{} +ALLA,crm_lead_channel,line_oa,Line OA,,,,4,true,{} +ALLA,crm_lead_channel,visit,Visit,,,,5,true,{} +ALLA,crm_lead_channel,walk_in,Walk In,,,,6,true,{} +ALLA,crm_lead_channel,facebook,Facebook,,,,7,true,{} +ALLA,crm_lead_channel,website_contact,Website Contact,,,,8,true,{} +ALLA,crm_lead_channel,bci,BCI,,,,9,true,{} + +ALLA,crm_lead_awareness,google_search,Google Search / Website,,,,1,true,{} +ALLA,crm_lead_awareness,social_media,Social media,,,,2,true,{} +ALLA,crm_lead_awareness,email_marketing,Email Marketing,,,,3,true,{} +ALLA,crm_lead_awareness,visit,Visit,,,,4,true,{} +ALLA,crm_lead_awareness,exhibition_event,Exhibition / Event,,,,5,true,{} +ALLA,crm_lead_awareness,brochure_catalog,Brochure / Catalog,,,,6,true,{} +ALLA,crm_lead_awareness,word_of_mouth,Word of mouth,,,,7,true,{} +ALLA,crm_lead_awareness,corporate_car,Corporate Car,,,,8,true,{} +ALLA,crm_lead_awareness,product_label,Product Label,,,,9,true,{} +ALLA,crm_lead_awareness,re_purchasing,Re-purchasing,,,,10,true,{} +ALLA,crm_lead_awareness,introducing,Introducing,,,,11,true,{} +ALLA,crm_lead_awareness,bci,BCI,,,,12,true,{} + +ALLA,crm_lead_priority,high,High,,,,1,true,{} +ALLA,crm_lead_priority,medium,Medium,,,,2,true,{} +ALLA,crm_lead_priority,low,Low,,,,3,true,{} + +ALLA,crm_followup_status,new,New,,,,1,true,{} +ALLA,crm_followup_status,contacted,Contacted,,,,2,true,{} +ALLA,crm_followup_status,follow_up,Follow Up,,,,3,true,{} +ALLA,crm_followup_status,closed,Closed,,,,4,true,{} + +ALLA,crm_lost_reason,price,ราคา,,,,1,true,{} +ALLA,crm_lost_reason,delivery_time,ระยะเวลาจัดส่ง,,,,2,true,{} +ALLA,crm_lost_reason,salesperson,พนักงานขาย,,,,3,true,{} +ALLA,crm_lost_reason,service,การบริการ,,,,4,true,{} +ALLA,crm_lost_reason,customer,ลูกค้า,,,,5,true,{} +ALLA,crm_lost_reason,product_quality,คุณภาพสินค้า,,,,6,true,{} +ALLA,crm_lost_reason,other,อื่นๆ,,,,7,true,{} +ALLA,crm_lost_reason,transfer,Transfer,,,,8,true,{} + +ALLA,crm_project_party_role,billing_customer,Billing Customer,,,,1,true,{} +ALLA,crm_project_party_role,end_customer,End Customer,,,,2,true,{} +ALLA,crm_project_party_role,contractor,Contractor,,,,3,true,{} +ALLA,crm_project_party_role,consultant,Consultant,,,,4,true,{} +ALLA,crm_project_party_role,developer,Developer,,,,5,true,{} +ALLA,crm_project_party_role,architect,Architect,,,,6,true,{} + +ALLA,crm_quotation_type,standard,Standard,,,,1,true,{} +ALLA,crm_quotation_type,revision,Revision,,,,2,true,{} + + +ONVALLA,crm_customer_group,construction,Construction,,,,1,true,{} +ONVALLA,crm_customer_group,industrial,Industrial,,,,2,true,{} + +ONVALLA,crm_customer_sub_group,factory,Factory,,crm_customer_group,industrial,1,true,{} +ONVALLA,crm_customer_sub_group,warehouse,Warehouse,,crm_customer_group,industrial,2,true,{} + +ONVALLA,crm_customer_status,active,Active,,,,1,true,{} +ONVALLA,crm_customer_status,inactive,Inactive,,,,2,true,{} +ONVALLA,crm_customer_status,prospect,Prospect,,,,3,true,{} + +ONVALLA,crm_customer_type,end_customer,End Customer,,,,1,true,{} +ONVALLA,crm_customer_type,contractor,Contractor,,,,2,true,{} +ONVALLA,crm_customer_type,consultant,Consultant,,,,3,true,{} +ONVALLA,crm_customer_type,developer,Developer,,,,4,true,{} + +ONVALLA,crm_lead_channel,call_in,Call in,,,,1,true,{} +ONVALLA,crm_lead_channel,ecommerce,E-commerce,,,,2,true,{} +ONVALLA,crm_lead_channel,email,E-mail,,,,3,true,{} +ONVALLA,crm_lead_channel,line_oa,Line OA,,,,4,true,{} +ONVALLA,crm_lead_channel,visit,Visit,,,,5,true,{} +ONVALLA,crm_lead_channel,walk_in,Walk In,,,,6,true,{} +ONVALLA,crm_lead_channel,facebook,Facebook,,,,7,true,{} +ONVALLA,crm_lead_channel,website_contact,Website Contact,,,,8,true,{} +ONVALLA,crm_lead_channel,bci,BCI,,,,9,true,{} + +ONVALLA,crm_lead_awareness,google_search,Google Search / Website,,,,1,true,{} +ONVALLA,crm_lead_awareness,social_media,Social media,,,,2,true,{} +ONVALLA,crm_lead_awareness,email_marketing,Email Marketing,,,,3,true,{} +ONVALLA,crm_lead_awareness,visit,Visit,,,,4,true,{} +ONVALLA,crm_lead_awareness,exhibition_event,Exhibition / Event,,,,5,true,{} +ONVALLA,crm_lead_awareness,brochure_catalog,Brochure / Catalog,,,,6,true,{} +ONVALLA,crm_lead_awareness,word_of_mouth,Word of mouth,,,,7,true,{} +ONVALLA,crm_lead_awareness,corporate_car,Corporate Car,,,,8,true,{} +ONVALLA,crm_lead_awareness,product_label,Product Label,,,,9,true,{} +ONVALLA,crm_lead_awareness,re_purchasing,Re-purchasing,,,,10,true,{} +ONVALLA,crm_lead_awareness,introducing,Introducing,,,,11,true,{} +ONVALLA,crm_lead_awareness,bci,BCI,,,,12,true,{} + +ONVALLA,crm_lead_priority,high,High,,,,1,true,{} +ONVALLA,crm_lead_priority,medium,Medium,,,,2,true,{} +ONVALLA,crm_lead_priority,low,Low,,,,3,true,{} + +ONVALLA,crm_followup_status,new,New,,,,1,true,{} +ONVALLA,crm_followup_status,contacted,Contacted,,,,2,true,{} +ONVALLA,crm_followup_status,follow_up,Follow Up,,,,3,true,{} +ONVALLA,crm_followup_status,closed,Closed,,,,4,true,{} + +ONVALLA,crm_lost_reason,price,ราคา,,,,1,true,{} +ONVALLA,crm_lost_reason,delivery_time,ระยะเวลาจัดส่ง,,,,2,true,{} +ONVALLA,crm_lost_reason,salesperson,พนักงานขาย,,,,3,true,{} +ONVALLA,crm_lost_reason,service,การบริการ,,,,4,true,{} +ONVALLA,crm_lost_reason,customer,ลูกค้า,,,,5,true,{} +ONVALLA,crm_lost_reason,product_quality,คุณภาพสินค้า,,,,6,true,{} +ONVALLA,crm_lost_reason,other,อื่นๆ,,,,7,true,{} +ONVALLA,crm_lost_reason,transfer,Transfer,,,,8,true,{} + +ONVALLA,crm_project_party_role,billing_customer,Billing Customer,,,,1,true,{} +ONVALLA,crm_project_party_role,end_customer,End Customer,,,,2,true,{} +ONVALLA,crm_project_party_role,contractor,Contractor,,,,3,true,{} +ONVALLA,crm_project_party_role,consultant,Consultant,,,,4,true,{} +ONVALLA,crm_project_party_role,developer,Developer,,,,5,true,{} +ONVALLA,crm_project_party_role,architect,Architect,,,,6,true,{} + +ONVALLA,crm_quotation_type,standard,Standard,,,,1,true,{} +ONVALLA,crm_quotation_type,revision,Revision,,,,2,true,{} diff --git a/setup/ALLA-ONVALLA/memberships.csv b/setup/ALLA-ONVALLA/memberships.csv new file mode 100644 index 0000000..8f5bd1d --- /dev/null +++ b/setup/ALLA-ONVALLA/memberships.csv @@ -0,0 +1,27 @@ +organization_code,user_email,membership_role,business_role,permissions,branch_scope_codes,product_type_scope_codes +ALLA,super_admin@allaos.local,admin,system_admin,*,*,* +ALLA,gm.uat@allaos.local,user,general_manager,crm.dashboard.read|crm.customer.read|crm.lead.read|crm.opportunity.read|crm.quotation.read|crm.approval.read,head_office|eastern|northern,crane|dockdoor|solar|service +ALLA,ceo.uat@allaos.local,user,executive,crm.dashboard.read|crm.approval.read,head_office|eastern|northern,crane|dockdoor|solar|service +ALLA,head_mk.uat@allaos.local,user,marketing_manager,crm.dashboard.read|crm.lead.read|crm.lead.create|crm.lead.update|crm.lead.assign,head_office,crane|dockdoor|solar +ALLA,mk.uat@allaos.local,user,marketing,crm.lead.read|crm.lead.create,head_office,crane|dockdoor|solar +ALLA,manager.uat@allaos.local,user,sales_manager,crm.dashboard.read|crm.customer.read|crm.opportunity.read|crm.quotation.read|crm.approval.read,head_office|eastern|northern,crane|dockdoor|solar|service +ALLA,mgr_crane.uat@allaos.local,user,sales_manager,crm.dashboard.read|crm.customer.read|crm.opportunity.read|crm.quotation.read|crm.approval.read,eastern,crane +ALLA,sales_crane.uat@allaos.local,user,sales,crm.customer.read|crm.opportunity.read|crm.quotation.read,eastern,crane +ALLA,mgr_dock.uat@allaos.local,user,sales_manager,crm.dashboard.read|crm.customer.read|crm.opportunity.read|crm.quotation.read|crm.approval.read,head_office,dockdoor +ALLA,sales_dock.uat@allaos.local,user,sales,crm.customer.read|crm.opportunity.read|crm.quotation.read,head_office,dockdoor +ALLA,mgr_solar.uat@allaos.local,user,sales_manager,crm.dashboard.read|crm.customer.read|crm.opportunity.read|crm.quotation.read|crm.approval.read,northern,solar +ALLA,sales_solar.uat@allaos.local,user,sales,crm.customer.read|crm.opportunity.read|crm.quotation.read,northern,solar +ALLA,sales.uat@allaos.local,user,sales,crm.customer.read|crm.opportunity.read|crm.quotation.read,head_office|eastern,crane|dockdoor + +ONVALLA,gm.onvalla@allaos.local,user,general_manager,crm.dashboard.read|crm.customer.read|crm.lead.read|crm.opportunity.read|crm.quotation.read|crm.approval.read,head_office|eastern|northern,crane|dockdoor|solar|service +ONVALLA,ceo.onvalla@allaos.local,user,executive,crm.dashboard.read|crm.approval.read,head_office|eastern|northern,crane|dockdoor|solar|service +ONVALLA,head_mk.onvalla@allaos.local,user,marketing_manager,crm.dashboard.read|crm.lead.read|crm.lead.create|crm.lead.update|crm.lead.assign,head_office,crane|dockdoor|solar +ONVALLA,mk.onvalla@allaos.local,user,marketing,crm.lead.read|crm.lead.create,head_office,crane|dockdoor|solar +ONVALLA,manager.onvalla@allaos.local,user,sales_manager,crm.dashboard.read|crm.customer.read|crm.opportunity.read|crm.quotation.read|crm.approval.read,head_office|eastern|northern,crane|dockdoor|solar|service +ONVALLA,mgr_crane.onvalla@allaos.local,user,sales_manager,crm.dashboard.read|crm.customer.read|crm.opportunity.read|crm.quotation.read|crm.approval.read,eastern,crane +ONVALLA,sales_crane.onvalla@allaos.local,user,sales,crm.customer.read|crm.opportunity.read|crm.quotation.read,eastern,crane +ONVALLA,mgr_dock.onvalla@allaos.local,user,sales_manager,crm.dashboard.read|crm.customer.read|crm.opportunity.read|crm.quotation.read|crm.approval.read,head_office,dockdoor +ONVALLA,sales_dock.onvalla@allaos.local,user,sales,crm.customer.read|crm.opportunity.read|crm.quotation.read,head_office,dockdoor +ONVALLA,mgr_solar.onvalla@allaos.local,user,sales_manager,crm.dashboard.read|crm.customer.read|crm.opportunity.read|crm.quotation.read|crm.approval.read,northern,solar +ONVALLA,sales_solar.onvalla@allaos.local,user,sales,crm.customer.read|crm.opportunity.read|crm.quotation.read,northern,solar +ONVALLA,sales.onvalla@allaos.local,user,sales,crm.customer.read|crm.opportunity.read|crm.quotation.read,head_office|eastern,crane|dockdoor diff --git a/setup/ALLA-ONVALLA/organizations.csv b/setup/ALLA-ONVALLA/organizations.csv new file mode 100644 index 0000000..e474f69 --- /dev/null +++ b/setup/ALLA-ONVALLA/organizations.csv @@ -0,0 +1,3 @@ +organization_code,organization_name,slug,created_by_email,tax_id,default_currency,phone,email,address,logo_url,plan,status +ALLA,ALLA Company Limited,alla,super_admin@allaos.local,1200101745173,THB,02-000-0000,admin@alla.example,99/9 Rama IX Road Bangkok,,enterprise,active +ONVALLA,ONVALLA Demo Company,onvalla,super_admin@allaos.local,1200101745178,THB,02-000-0099,admin@onvalla.example,88/8 Demo Road Bangkok,,enterprise,active diff --git a/setup/ALLA-ONVALLA/product-types.csv b/setup/ALLA-ONVALLA/product-types.csv new file mode 100644 index 0000000..30ea703 --- /dev/null +++ b/setup/ALLA-ONVALLA/product-types.csv @@ -0,0 +1,5 @@ +organization_code,product_type_code,product_type_name,document_prefix,sort_order,is_active,metadata_json +ALLA,crane,Crane,CR,1,true,{} +ALLA,dockdoor,DocK Door,DK,2,true,{} +ONVALLA,crane,Crane,CR,3,true,{} +ONVALLA,solarcell,Solar cell,SC,4,true,{} diff --git a/setup/ALLA-ONVALLA/users.csv b/setup/ALLA-ONVALLA/users.csv new file mode 100644 index 0000000..85237c7 --- /dev/null +++ b/setup/ALLA-ONVALLA/users.csv @@ -0,0 +1,40 @@ +email,name,system_role,password,active_organization_code,image_url,reset_password +super_admin@allaos.local,System Administrator,super_admin,ChangeMe123!,ALLA,,FALSE + +gm.uat@allaos.local,General Manager,user,ChangeMe123!,ALLA,,FALSE +ceo.uat@allaos.local,Chief Executive Officer,user,ChangeMe123!,ALLA,,FALSE + +head_mk.uat@allaos.local,Head of Marketing,user,ChangeMe123!,ALLA,,FALSE +mk.uat@allaos.local,Marketing User,user,ChangeMe123!,ALLA,,FALSE + +manager.uat@allaos.local,Sales Director,user,ChangeMe123!,ALLA,,FALSE + +mgr_crane.uat@allaos.local,Crane Sales Manager,user,ChangeMe123!,ALLA,,FALSE +sales_crane.uat@allaos.local,Crane Sales,user,ChangeMe123!,ALLA,,FALSE + +mgr_dock.uat@allaos.local,Dock Door Sales Manager,user,ChangeMe123!,ALLA,,FALSE +sales_dock.uat@allaos.local,Dock Door Sales,user,ChangeMe123!,ALLA,,FALSE + +mgr_solar.uat@allaos.local,Solar Sales Manager,user,ChangeMe123!,ALLA,,FALSE +sales_solar.uat@allaos.local,Solar Sales,user,ChangeMe123!,ALLA,,FALSE + +sales.uat@allaos.local,General Sales,user,ChangeMe123!,ALLA,,FALSE + +gm.onvalla@allaos.local,General Manager,user,ChangeMe123!,ONVALLA,,FALSE +ceo.onvalla@allaos.local,Chief Executive Officer,user,ChangeMe123!,ONVALLA,,FALSE + +head_mk.onvalla@allaos.local,Head of Marketing,user,ChangeMe123!,ONVALLA,,FALSE +mk.onvalla@allaos.local,Marketing User,user,ChangeMe123!,ONVALLA,,FALSE + +manager.onvalla@allaos.local,Sales Director,user,ChangeMe123!,ONVALLA,,FALSE + +mgr_crane.onvalla@allaos.local,Crane Sales Manager,user,ChangeMe123!,ONVALLA,,FALSE +sales_crane.onvalla@allaos.local,Crane Sales,user,ChangeMe123!,ONVALLA,,FALSE + +mgr_dock.onvalla@allaos.local,Dock Door Sales Manager,user,ChangeMe123!,ONVALLA,,FALSE +sales_dock.onvalla@allaos.local,Dock Door Sales,user,ChangeMe123!,ONVALLA,,FALSE + +mgr_solar.onvalla@allaos.local,Solar Sales Manager,user,ChangeMe123!,ONVALLA,,FALSE +sales_solar.onvalla@allaos.local,Solar Sales,user,ChangeMe123!,ONVALLA,,FALSE + +sales.onvalla@allaos.local,General Sales,user,ChangeMe123!,ONVALLA,,FALSE diff --git a/src/app/administration/setup/page.tsx b/src/app/administration/setup/page.tsx index ab06383..ef6174a 100644 --- a/src/app/administration/setup/page.tsx +++ b/src/app/administration/setup/page.tsx @@ -1,23 +1,33 @@ -import { redirect } from 'next/navigation'; -import { BootstrapSetup } from '@/features/setup/components/bootstrap-setup'; -import { getBootstrapSetupStatus } from '@/features/setup/server/setup-bootstrap.service'; +import { redirect } from "next/navigation"; + +import { SetupWizard } from "@/features/setup/components/setup-wizard"; +import { getBootstrapSetupStatus } from "@/features/setup/server/setup-bootstrap.service"; export const metadata = { - title: 'ALLA OS Setup' + title: "ALLA OS Setup", }; export default async function BootstrapSetupPage() { const status = await getBootstrapSetupStatus(); if (status.initialized) { - redirect('/auth/sign-in'); + redirect("/auth/sign-in"); } return ( - +
+
+
+

+ ALLA OS Setup +

+

+ Preview setup CSV files from SETUP_IMPORT_DIR, run the import, then + complete setup. +

+
+ +
+
); } diff --git a/src/app/api/setup/bootstrap/commit/route.ts b/src/app/api/setup/bootstrap/commit/route.ts index cde1be5..9796227 100644 --- a/src/app/api/setup/bootstrap/commit/route.ts +++ b/src/app/api/setup/bootstrap/commit/route.ts @@ -1,15 +1,23 @@ import { NextRequest, NextResponse } from 'next/server'; + import { commitSetupCsvImport } from '@/features/setup/server/csv-commit.service'; import { createCsvImportSeedManifest } from '@/features/setup/server/seed-manifest.service'; +import type { ImportReport } from '@/features/setup/server/csv/import-report'; +import type { CsvPreviewFileInput } from '@/features/setup/server/csv/types'; import { - assertBootstrapStepFiles, assertBootstrapSetupAvailable, + assertBootstrapStepFiles, assertBootstrapToken, BOOTSTRAP_ACTOR_ID, + BOOTSTRAP_OPTIONAL_TEMPLATE_FILES, + BOOTSTRAP_REQUIRED_STEPS, + BOOTSTRAP_REQUIRED_TEMPLATE_FILES, BootstrapSetupError, setupStepStatusFromImportReport, + setupStepStatusFromReportStatus, toSerializableRecord } from '@/features/setup/server/setup-bootstrap.service'; +import { readConfiguredSetupImportSource } from '@/features/setup/server/setup-import-source.service'; import { saveSetupStep, startSetupRun } from '@/features/setup/server/setup-state.service'; function isFile(value: FormDataEntryValue): value is File { @@ -22,58 +30,156 @@ function isFile(value: FormDataEntryValue): value is File { ); } -function parseBoolean(value: FormDataEntryValue | null): boolean { +function parseBoolean(value: FormDataEntryValue | string | boolean | undefined | null): boolean { + if (typeof value === 'boolean') return value; if (typeof value !== 'string') return false; return value.trim().toLowerCase() === 'true'; } +async function readMultipartFiles(formData: FormData): Promise { + const uploadedFiles = Array.from(formData.values()).filter(isFile); + + return Promise.all( + uploadedFiles.map(async (file) => ({ + fileName: file.name, + bytes: new Uint8Array(await file.arrayBuffer()) + })) + ); +} + +async function saveConfiguredCommitSteps(report: ImportReport) { + await Promise.all( + BOOTSTRAP_REQUIRED_STEPS.map((step) => { + const file = report.files.find( + (candidate) => candidate.fileName.toLowerCase() === step.fileName.toLowerCase() + ); + + if (!file) return Promise.resolve(); + + return saveSetupStep({ + stepKey: step.stepKey, + status: setupStepStatusFromReportStatus(file.status), + inputHash: report.committedHash, + output: toSerializableRecord({ + status: file.status, + generatedAt: report.generatedAt, + previewHash: report.previewHash, + committedHash: report.committedHash, + dryRun: report.dryRun, + file: { + fileName: file.fileName, + template: file.template, + status: file.status, + imported: file.imported, + updated: file.updated, + skipped: file.skipped, + errors: file.errors, + warnings: file.warnings + } + }), + errors: file.errors, + warnings: file.warnings, + skipStaleInvalidation: true + }); + }) + ); +} + +async function commitConfiguredDirectory(request: NextRequest) { + const body = (await request.json().catch(() => ({}))) as { + bootstrapToken?: string; + source?: string; + previewHash?: string; + dryRun?: boolean | string; + }; + + assertBootstrapToken(body.bootstrapToken ?? request.headers.get('x-bootstrap-setup-token') ?? ''); + + if (body.source !== 'configured-directory') { + return NextResponse.json({ message: 'Unsupported bootstrap commit source.' }, { status: 400 }); + } + + const source = await readConfiguredSetupImportSource({ + requiredFiles: BOOTSTRAP_REQUIRED_TEMPLATE_FILES, + optionalFiles: BOOTSTRAP_OPTIONAL_TEMPLATE_FILES + }); + + if (source.report.errors.length > 0) { + return NextResponse.json( + { message: 'Configured setup import folder is not ready.', importSource: source.report }, + { status: 400 } + ); + } + + await startSetupRun({ actorId: BOOTSTRAP_ACTOR_ID, mode: 'production', targetOrganizationId: null }); + + const report = await commitSetupCsvImport({ + files: source.files, + previewHash: body.previewHash ?? '', + dryRun: parseBoolean(body.dryRun), + actorId: BOOTSTRAP_ACTOR_ID + }); + + if (!report.dryRun && report.status !== 'FAIL') { + await createCsvImportSeedManifest({ files: source.files, report, actorId: BOOTSTRAP_ACTOR_ID }); + } + + await saveConfiguredCommitSteps(report); + + return NextResponse.json({ ...report, importSource: source.report }, { status: report.status === 'FAIL' ? 400 : 200 }); +} + +async function commitUploadedStep(request: NextRequest) { + const formData = await request.formData(); + assertBootstrapToken(String(formData.get('bootstrapToken') ?? request.headers.get('x-bootstrap-setup-token') ?? '')); + + const stepKey = String(formData.get('stepKey') ?? ''); + const previewHash = String(formData.get('previewHash') ?? ''); + const dryRun = parseBoolean(formData.get('dryRun')); + const files = await readMultipartFiles(formData); + + if (files.length === 0) { + return NextResponse.json( + { message: 'Upload at least one setup CSV file using multipart/form-data.' }, + { status: 400 } + ); + } + + const step = assertBootstrapStepFiles(stepKey, files); + await startSetupRun({ actorId: BOOTSTRAP_ACTOR_ID, mode: 'production', targetOrganizationId: null }); + + const report = await commitSetupCsvImport({ + files, + previewHash, + dryRun, + actorId: BOOTSTRAP_ACTOR_ID + }); + + if (!dryRun && report.status !== 'FAIL') { + await createCsvImportSeedManifest({ files, report, actorId: BOOTSTRAP_ACTOR_ID }); + } + + await saveSetupStep({ + stepKey: step.stepKey, + status: setupStepStatusFromImportReport(report), + inputHash: report.committedHash, + output: toSerializableRecord(report), + errors: report.errors, + warnings: report.warnings + }); + + return NextResponse.json(report, { status: report.status === 'FAIL' ? 400 : 200 }); +} + export async function POST(request: NextRequest) { try { await assertBootstrapSetupAvailable(); - const formData = await request.formData(); - assertBootstrapToken(String(formData.get('bootstrapToken') ?? request.headers.get('x-bootstrap-setup-token') ?? '')); - - const stepKey = String(formData.get('stepKey') ?? ''); - const previewHash = String(formData.get('previewHash') ?? ''); - const dryRun = parseBoolean(formData.get('dryRun')); - const uploadedFiles = Array.from(formData.values()).filter(isFile); - - if (uploadedFiles.length === 0) { - return NextResponse.json({ message: 'Upload at least one setup CSV file using multipart/form-data.' }, { status: 400 }); + if (request.headers.get('content-type')?.includes('application/json')) { + return await commitConfiguredDirectory(request); } - const files = await Promise.all( - uploadedFiles.map(async (file) => ({ - fileName: file.name, - bytes: new Uint8Array(await file.arrayBuffer()) - })) - ); - - const step = assertBootstrapStepFiles(stepKey, files); - await startSetupRun({ actorId: BOOTSTRAP_ACTOR_ID, mode: 'production', targetOrganizationId: null }); - - const report = await commitSetupCsvImport({ - files, - previewHash, - dryRun, - actorId: BOOTSTRAP_ACTOR_ID - }); - - if (!dryRun && report.status !== 'FAIL') { - await createCsvImportSeedManifest({ files, report, actorId: BOOTSTRAP_ACTOR_ID }); - } - - await saveSetupStep({ - stepKey: step.stepKey, - status: setupStepStatusFromImportReport(report), - inputHash: report.committedHash, - output: toSerializableRecord(report), - errors: report.errors, - warnings: report.warnings - }); - - return NextResponse.json(report, { status: report.status === 'FAIL' ? 400 : 200 }); + return await commitUploadedStep(request); } catch (error) { if (error instanceof BootstrapSetupError) { return NextResponse.json({ message: error.message, details: error.details }, { status: error.status }); diff --git a/src/app/api/setup/bootstrap/preview/route.ts b/src/app/api/setup/bootstrap/preview/route.ts index 97d5484..40f512c 100644 --- a/src/app/api/setup/bootstrap/preview/route.ts +++ b/src/app/api/setup/bootstrap/preview/route.ts @@ -1,15 +1,24 @@ import { NextRequest, NextResponse } from 'next/server'; + import { previewSetupCsvImport } from '@/features/setup/server/csv-preview.service'; import { - assertBootstrapStepFiles, assertBootstrapSetupAvailable, + assertBootstrapStepFiles, assertBootstrapToken, BOOTSTRAP_ACTOR_ID, + BOOTSTRAP_OPTIONAL_TEMPLATE_FILES, + BOOTSTRAP_REQUIRED_STEPS, + BOOTSTRAP_REQUIRED_TEMPLATE_FILES, BootstrapSetupError, + sanitizeCsvPreviewFileForPersistence, + sanitizeCsvPreviewForResponse, setupStepStatusFromPreview, + setupStepStatusFromReportStatus, toSerializableRecord } from '@/features/setup/server/setup-bootstrap.service'; +import { readConfiguredSetupImportSource } from '@/features/setup/server/setup-import-source.service'; import { saveSetupStep, startSetupRun } from '@/features/setup/server/setup-state.service'; +import type { CsvPreviewFileInput, CsvPreviewResult } from '@/features/setup/server/csv/types'; function isFile(value: FormDataEntryValue): value is File { return ( @@ -21,42 +30,109 @@ function isFile(value: FormDataEntryValue): value is File { ); } +async function saveConfiguredPreviewSteps(preview: CsvPreviewResult) { + await Promise.all( + BOOTSTRAP_REQUIRED_STEPS.map((step) => { + const file = preview.files.find( + (candidate) => candidate.fileName.toLowerCase() === step.fileName.toLowerCase() + ); + + if (!file) return Promise.resolve(); + + return saveSetupStep({ + stepKey: step.stepKey, + status: setupStepStatusFromReportStatus(file.status), + inputHash: preview.previewHash, + output: toSerializableRecord(sanitizeCsvPreviewFileForPersistence(preview, step.fileName)), + errors: file.errors, + warnings: file.warnings, + skipStaleInvalidation: true + }); + }) + ); +} + +async function readMultipartFiles(formData: FormData): Promise { + const uploadedFiles = Array.from(formData.values()).filter(isFile); + + return Promise.all( + uploadedFiles.map(async (file) => ({ + fileName: file.name, + bytes: new Uint8Array(await file.arrayBuffer()) + })) + ); +} + +async function previewConfiguredDirectory(request: NextRequest) { + const body = (await request.json().catch(() => ({}))) as { + bootstrapToken?: string; + source?: string; + }; + + assertBootstrapToken(body.bootstrapToken ?? request.headers.get('x-bootstrap-setup-token') ?? ''); + + if (body.source !== 'configured-directory') { + return NextResponse.json({ message: 'Unsupported bootstrap preview source.' }, { status: 400 }); + } + + const source = await readConfiguredSetupImportSource({ + requiredFiles: BOOTSTRAP_REQUIRED_TEMPLATE_FILES, + optionalFiles: BOOTSTRAP_OPTIONAL_TEMPLATE_FILES + }); + + if (source.report.errors.length > 0) { + return NextResponse.json( + { message: 'Configured setup import folder is not ready.', importSource: source.report }, + { status: 400 } + ); + } + + await startSetupRun({ actorId: BOOTSTRAP_ACTOR_ID, mode: 'production', targetOrganizationId: null }); + const preview = await previewSetupCsvImport(source.files); + await saveConfiguredPreviewSteps(preview); + + return NextResponse.json({ ...sanitizeCsvPreviewForResponse(preview), importSource: source.report }); +} + +async function previewUploadedStep(request: NextRequest) { + const formData = await request.formData(); + assertBootstrapToken(String(formData.get('bootstrapToken') ?? request.headers.get('x-bootstrap-setup-token') ?? '')); + + const stepKey = String(formData.get('stepKey') ?? ''); + const files = await readMultipartFiles(formData); + + if (files.length === 0) { + return NextResponse.json( + { message: 'Upload at least one setup CSV file using multipart/form-data.' }, + { status: 400 } + ); + } + + const step = assertBootstrapStepFiles(stepKey, files); + await startSetupRun({ actorId: BOOTSTRAP_ACTOR_ID, mode: 'production', targetOrganizationId: null }); + + const preview = await previewSetupCsvImport(files); + await saveSetupStep({ + stepKey: step.stepKey, + status: setupStepStatusFromPreview(preview), + inputHash: preview.previewHash, + output: toSerializableRecord(sanitizeCsvPreviewFileForPersistence(preview, step.fileName)), + errors: preview.files.flatMap((file) => file.errors), + warnings: preview.files.flatMap((file) => file.warnings) + }); + + return NextResponse.json(sanitizeCsvPreviewForResponse(preview)); +} + export async function POST(request: NextRequest) { try { await assertBootstrapSetupAvailable(); - const formData = await request.formData(); - assertBootstrapToken(String(formData.get('bootstrapToken') ?? request.headers.get('x-bootstrap-setup-token') ?? '')); - - const stepKey = String(formData.get('stepKey') ?? ''); - const uploadedFiles = Array.from(formData.values()).filter(isFile); - - if (uploadedFiles.length === 0) { - return NextResponse.json({ message: 'Upload at least one setup CSV file using multipart/form-data.' }, { status: 400 }); + if (request.headers.get('content-type')?.includes('application/json')) { + return await previewConfiguredDirectory(request); } - const files = await Promise.all( - uploadedFiles.map(async (file) => ({ - fileName: file.name, - bytes: new Uint8Array(await file.arrayBuffer()) - })) - ); - - const step = assertBootstrapStepFiles(stepKey, files); - - await startSetupRun({ actorId: BOOTSTRAP_ACTOR_ID, mode: 'production', targetOrganizationId: null }); - const preview = await previewSetupCsvImport(files); - - await saveSetupStep({ - stepKey: step.stepKey, - status: setupStepStatusFromPreview(preview), - inputHash: preview.previewHash, - output: toSerializableRecord(preview), - errors: preview.files.flatMap((file) => file.errors), - warnings: preview.files.flatMap((file) => file.warnings) - }); - - return NextResponse.json(preview); + return await previewUploadedStep(request); } catch (error) { if (error instanceof BootstrapSetupError) { return NextResponse.json({ message: error.message, details: error.details }, { status: error.status }); @@ -66,14 +142,6 @@ export async function POST(request: NextRequest) { return NextResponse.json({ message: 'Upload setup CSV files using multipart/form-data.' }, { status: 400 }); } - console.error('Bootstrap preview failed'); - if (error instanceof Error) { - for (const line of (error.stack ?? error.message).split('\n').slice(0, 12)) { - console.error(line); - } - } else { - console.error(error); - } return NextResponse.json({ message: 'Unable preview bootstrap setup CSV import' }, { status: 500 }); } } diff --git a/src/app/api/setup/bootstrap/run/current/route.ts b/src/app/api/setup/bootstrap/run/current/route.ts new file mode 100644 index 0000000..0db95a6 --- /dev/null +++ b/src/app/api/setup/bootstrap/run/current/route.ts @@ -0,0 +1,28 @@ +import { NextRequest, NextResponse } from "next/server"; +import { + assertBootstrapSetupAvailable, + assertBootstrapToken, + BootstrapSetupError, +} from "@/features/setup/server/setup-bootstrap.service"; +import { getCurrentSetupRun } from "@/features/setup/server/setup-state.service"; + +export async function GET(request: NextRequest) { + try { + await assertBootstrapSetupAvailable(); + assertBootstrapToken(request.headers.get("x-bootstrap-setup-token") ?? ""); + + return NextResponse.json(await getCurrentSetupRun()); + } catch (error) { + if (error instanceof BootstrapSetupError) { + return NextResponse.json( + { message: error.message, details: error.details }, + { status: error.status }, + ); + } + + return NextResponse.json( + { message: "Unable load bootstrap setup run" }, + { status: 500 }, + ); + } +} diff --git a/src/app/api/setup/import/commit/route.ts b/src/app/api/setup/import/commit/route.ts index 85abd8c..3cf3c5f 100644 --- a/src/app/api/setup/import/commit/route.ts +++ b/src/app/api/setup/import/commit/route.ts @@ -1,55 +1,109 @@ import { NextRequest, NextResponse } from 'next/server'; + import { commitSetupCsvImport } from '@/features/setup/server/csv-commit.service'; import { createCsvImportSeedManifest } from '@/features/setup/server/seed-manifest.service'; +import { + BOOTSTRAP_OPTIONAL_TEMPLATE_FILES, + BOOTSTRAP_REQUIRED_TEMPLATE_FILES +} from '@/features/setup/server/setup-bootstrap.service'; +import { readConfiguredSetupImportSource } from '@/features/setup/server/setup-import-source.service'; import { AuthError, requireSystemRole } from '@/lib/auth/session'; +import type { CsvPreviewFileInput } from '@/features/setup/server/csv/types'; function isFile(value: FormDataEntryValue): value is File { return typeof value === 'object' && 'arrayBuffer' in value && 'name' in value; } -function parseBoolean(value: FormDataEntryValue | null): boolean { +function parseBoolean(value: FormDataEntryValue | string | boolean | undefined | null): boolean { + if (typeof value === 'boolean') return value; if (typeof value !== 'string') return false; return value.trim().toLowerCase() === 'true'; } +async function readMultipartFiles(formData: FormData): Promise { + const uploadedFiles = Array.from(formData.values()).filter(isFile); + + return Promise.all( + uploadedFiles.map(async (file) => ({ + fileName: file.name, + bytes: new Uint8Array(await file.arrayBuffer()) + })) + ); +} + +async function commitConfiguredDirectory(request: NextRequest, actorId: string) { + const body = (await request.json().catch(() => ({}))) as { + source?: string; + previewHash?: string; + dryRun?: boolean | string; + }; + + if (body.source !== 'configured-directory') { + return NextResponse.json({ message: 'Unsupported setup import commit source.' }, { status: 400 }); + } + + const source = await readConfiguredSetupImportSource({ + requiredFiles: BOOTSTRAP_REQUIRED_TEMPLATE_FILES, + optionalFiles: BOOTSTRAP_OPTIONAL_TEMPLATE_FILES + }); + + if (source.report.errors.length > 0) { + return NextResponse.json( + { message: 'Configured setup import folder is not ready.', importSource: source.report }, + { status: 400 } + ); + } + + const report = await commitSetupCsvImport({ + files: source.files, + previewHash: body.previewHash ?? '', + dryRun: parseBoolean(body.dryRun), + actorId + }); + + if (!report.dryRun && report.status !== 'FAIL') { + await createCsvImportSeedManifest({ files: source.files, report, actorId }); + } + + return NextResponse.json({ ...report, importSource: source.report }, { status: report.status === 'FAIL' ? 400 : 200 }); +} + +async function commitUploadedFiles(request: NextRequest, actorId: string) { + const formData = await request.formData(); + const previewHash = String(formData.get('previewHash') ?? ''); + const dryRun = parseBoolean(formData.get('dryRun')); + const files = await readMultipartFiles(formData); + + if (files.length === 0) { + return NextResponse.json( + { message: 'Upload at least one setup CSV file using multipart/form-data.' }, + { status: 400 } + ); + } + + const report = await commitSetupCsvImport({ + files, + previewHash, + dryRun, + actorId + }); + + if (!dryRun && report.status !== 'FAIL') { + await createCsvImportSeedManifest({ files, report, actorId }); + } + + return NextResponse.json(report, { status: report.status === 'FAIL' ? 400 : 200 }); +} + export async function POST(request: NextRequest) { try { const session = await requireSystemRole('super_admin'); - const formData = await request.formData(); - const previewHash = String(formData.get('previewHash') ?? ''); - const dryRun = parseBoolean(formData.get('dryRun')); - const uploadedFiles = Array.from(formData.values()).filter(isFile); - if (uploadedFiles.length === 0) { - return NextResponse.json( - { message: 'Upload at least one setup CSV file using multipart/form-data.' }, - { status: 400 } - ); + if (request.headers.get('content-type')?.includes('application/json')) { + return await commitConfiguredDirectory(request, session.user.id); } - const files = await Promise.all( - uploadedFiles.map(async (file) => ({ - fileName: file.name, - bytes: new Uint8Array(await file.arrayBuffer()) - })) - ); - - const report = await commitSetupCsvImport({ - files, - previewHash, - dryRun, - actorId: session.user.id - }); - - if (!dryRun && report.status !== 'FAIL') { - await createCsvImportSeedManifest({ - files, - report, - actorId: session.user.id - }); - } - - return NextResponse.json(report, { status: report.status === 'FAIL' ? 400 : 200 }); + return await commitUploadedFiles(request, session.user.id); } catch (error) { if (error instanceof AuthError) { return NextResponse.json({ message: error.message }, { status: error.status }); diff --git a/src/app/api/setup/import/preview/route.ts b/src/app/api/setup/import/preview/route.ts index da0b368..e3ed3c4 100644 --- a/src/app/api/setup/import/preview/route.ts +++ b/src/app/api/setup/import/preview/route.ts @@ -1,34 +1,77 @@ import { NextRequest, NextResponse } from 'next/server'; + import { previewSetupCsvImport } from '@/features/setup/server/csv-preview.service'; +import { + BOOTSTRAP_OPTIONAL_TEMPLATE_FILES, + BOOTSTRAP_REQUIRED_TEMPLATE_FILES, + sanitizeCsvPreviewForResponse +} from '@/features/setup/server/setup-bootstrap.service'; +import { readConfiguredSetupImportSource } from '@/features/setup/server/setup-import-source.service'; import { AuthError, requireSystemRole } from '@/lib/auth/session'; +import type { CsvPreviewFileInput } from '@/features/setup/server/csv/types'; function isFile(value: FormDataEntryValue): value is File { return typeof value === 'object' && 'arrayBuffer' in value && 'name' in value; } +async function readMultipartFiles(formData: FormData): Promise { + const uploadedFiles = Array.from(formData.values()).filter(isFile); + + return Promise.all( + uploadedFiles.map(async (file) => ({ + fileName: file.name, + bytes: new Uint8Array(await file.arrayBuffer()) + })) + ); +} + +async function previewConfiguredDirectory(request: NextRequest) { + const body = (await request.json().catch(() => ({}))) as { source?: string }; + + if (body.source !== 'configured-directory') { + return NextResponse.json({ message: 'Unsupported setup import preview source.' }, { status: 400 }); + } + + const source = await readConfiguredSetupImportSource({ + requiredFiles: BOOTSTRAP_REQUIRED_TEMPLATE_FILES, + optionalFiles: BOOTSTRAP_OPTIONAL_TEMPLATE_FILES + }); + + if (source.report.errors.length > 0) { + return NextResponse.json( + { message: 'Configured setup import folder is not ready.', importSource: source.report }, + { status: 400 } + ); + } + + const preview = await previewSetupCsvImport(source.files); + return NextResponse.json({ ...sanitizeCsvPreviewForResponse(preview), importSource: source.report }); +} + +async function previewUploadedFiles(request: NextRequest) { + const formData = await request.formData(); + const files = await readMultipartFiles(formData); + + if (files.length === 0) { + return NextResponse.json( + { message: 'Upload at least one setup CSV file using multipart/form-data.' }, + { status: 400 } + ); + } + + const preview = await previewSetupCsvImport(files); + return NextResponse.json(sanitizeCsvPreviewForResponse(preview)); +} + export async function POST(request: NextRequest) { try { await requireSystemRole('super_admin'); - const formData = await request.formData(); - const uploadedFiles = Array.from(formData.values()).filter(isFile); - - if (uploadedFiles.length === 0) { - return NextResponse.json( - { message: 'Upload at least one setup CSV file using multipart/form-data.' }, - { status: 400 } - ); + if (request.headers.get('content-type')?.includes('application/json')) { + return await previewConfiguredDirectory(request); } - const files = await Promise.all( - uploadedFiles.map(async (file) => ({ - fileName: file.name, - bytes: new Uint8Array(await file.arrayBuffer()) - })) - ); - const preview = await previewSetupCsvImport(files); - - return NextResponse.json(preview); + return await previewUploadedFiles(request); } catch (error) { if (error instanceof AuthError) { return NextResponse.json({ message: error.message }, { status: error.status }); diff --git a/src/app/dashboard/administration/setup/page.tsx b/src/app/dashboard/administration/setup/page.tsx index cacf3cc..f0b2e0f 100644 --- a/src/app/dashboard/administration/setup/page.tsx +++ b/src/app/dashboard/administration/setup/page.tsx @@ -13,7 +13,7 @@ export default async function AdministrationSetupPage() { return ( diff --git a/src/features/setup/api/queries.ts b/src/features/setup/api/queries.ts index 406cef0..9f8789c 100644 --- a/src/features/setup/api/queries.ts +++ b/src/features/setup/api/queries.ts @@ -1,6 +1,8 @@ import { queryOptions } from '@tanstack/react-query'; + import { getBootstrapSetupStatus, + getCurrentBootstrapSetupRun, getCurrentSetupRun, getSetupProfiles, getSetupReadiness, @@ -16,10 +18,11 @@ export const setupKeys = { templates: (profileId?: string) => [...setupKeys.all, 'templates', profileId ?? 'crm_demo'] as const, profiles: (profileId?: string) => [...setupKeys.all, 'profiles', profileId ?? 'crm_demo'] as const, bootstrapStatus: () => [...setupKeys.all, 'bootstrap', 'status'] as const, + bootstrapRun: (bootstrapToken?: string) => + [...setupKeys.all, 'bootstrap', 'run', bootstrapToken ? 'token-provided' : 'no-token'] as const, run: () => [...setupKeys.all, 'run'] as const, report: () => [...setupKeys.all, 'run', 'report'] as const, - verification: (payload: SetupVerificationPayload = {}) => - [...setupKeys.all, 'verification', payload] as const + verification: (payload: SetupVerificationPayload = {}) => [...setupKeys.all, 'verification', payload] as const }; export const setupReadinessQueryOptions = (profileId?: string) => @@ -52,6 +55,12 @@ export const setupRunQueryOptions = () => queryFn: () => getCurrentSetupRun() }); +export const bootstrapSetupRunQueryOptions = (bootstrapToken?: string) => + queryOptions({ + queryKey: setupKeys.bootstrapRun(bootstrapToken), + queryFn: () => getCurrentBootstrapSetupRun(bootstrapToken) + }); + export const setupRunReportQueryOptions = () => queryOptions({ queryKey: setupKeys.report(), diff --git a/src/features/setup/api/service.ts b/src/features/setup/api/service.ts index 3396f97..d408b92 100644 --- a/src/features/setup/api/service.ts +++ b/src/features/setup/api/service.ts @@ -1,7 +1,8 @@ import { ApiClientError, apiClient } from '@/lib/api-client'; import type { - CsvPreviewResult, BootstrapSetupStatusResponse, + CompleteBootstrapSetupPayload, + CsvPreviewResult, ImportReport, SaveSetupStepPayload, SetupProfilesResponse, @@ -23,60 +24,6 @@ function withProfile(endpoint: string, profileId?: string) { return `${endpoint}${separator}profile=${encodeURIComponent(profileId)}`; } -export async function getSetupReadiness(profileId?: string): Promise { - return apiClient(withProfile('/setup/readiness', profileId)); -} - -export async function getSetupTemplates(profileId?: string): Promise { - return apiClient(withProfile('/setup/templates', profileId)); -} - -export async function getSetupProfiles(profileId?: string): Promise { - return apiClient(withProfile('/setup/profiles', profileId)); -} - -export async function getBootstrapSetupStatus(): Promise { - return apiClient('/setup/bootstrap/status'); -} - -export async function getCurrentSetupRun(): Promise { - return apiClient('/setup/run/current'); -} - -export async function startSetupRun(): Promise { - return apiClient('/setup/run/start', { - method: 'POST', - body: JSON.stringify({ mode: 'production' }) - }); -} - -export async function saveSetupStep(payload: SaveSetupStepPayload): Promise { - return apiClient('/setup/run/steps', { - method: 'POST', - body: JSON.stringify(payload) - }); -} - -export async function completeSetupRun(report: Record): Promise { - return apiClient('/setup/run/complete', { - method: 'POST', - body: JSON.stringify({ report }) - }); -} - -export async function getSetupRunReport(): Promise { - return apiClient('/setup/run/report'); -} - -export async function verifySetup( - payload: SetupVerificationPayload = {} -): Promise { - return apiClient('/setup/verify', { - method: 'POST', - body: JSON.stringify(payload) - }); -} - async function readUploadResponse(response: Response): Promise { if (response.ok) { return response.json() as Promise; @@ -98,6 +45,70 @@ function buildCsvFormData(files: File[]): FormData { return formData; } +function bootstrapHeaders(bootstrapToken?: string) { + return bootstrapToken ? { 'x-bootstrap-setup-token': bootstrapToken } : undefined; +} + +export async function getSetupReadiness(profileId?: string): Promise { + return apiClient(withProfile('/setup/readiness', profileId)); +} + +export async function getSetupTemplates(profileId?: string): Promise { + return apiClient(withProfile('/setup/templates', profileId)); +} + +export async function getSetupProfiles(profileId?: string): Promise { + return apiClient(withProfile('/setup/profiles', profileId)); +} + +export async function getBootstrapSetupStatus(): Promise { + return apiClient('/setup/bootstrap/status'); +} + +export async function getCurrentSetupRun(): Promise { + return apiClient('/setup/run/current'); +} + +export async function getCurrentBootstrapSetupRun(bootstrapToken?: string): Promise { + return readUploadResponse( + await fetch('/api/setup/bootstrap/run/current', { + headers: bootstrapHeaders(bootstrapToken) + }) + ); +} + +export async function startSetupRun(): Promise { + return apiClient('/setup/run/start', { + method: 'POST', + body: JSON.stringify({ mode: 'production' }) + }); +} + +export async function saveSetupStep(payload: SaveSetupStepPayload): Promise { + return apiClient('/setup/run/steps', { + method: 'POST', + body: JSON.stringify(payload) + }); +} + +export async function getSetupRunReport(): Promise { + return apiClient('/setup/run/report'); +} + +export async function completeSetupRun(report: Record): Promise { + return apiClient('/setup/run/complete', { + method: 'POST', + body: JSON.stringify({ report }) + }); +} + +export async function verifySetup(payload: SetupVerificationPayload): Promise { + return apiClient('/setup/verify', { + method: 'POST', + body: JSON.stringify(payload) + }); +} + export async function previewSetupCsvFiles(files: File[]): Promise { return readUploadResponse( await fetch('/api/setup/import/preview', { @@ -107,6 +118,16 @@ export async function previewSetupCsvFiles(files: File[]): Promise { + return readUploadResponse( + await fetch('/api/setup/import/preview', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ source: 'configured-directory' }) + }) + ); +} + export async function previewBootstrapCsvStep(input: { stepKey: string; file: File; @@ -124,6 +145,24 @@ export async function previewBootstrapCsvStep(input: { ); } +export async function previewConfiguredBootstrapCsv(input: { + bootstrapToken?: string; +} = {}): Promise { + return readUploadResponse( + await fetch('/api/setup/bootstrap/preview', { + method: 'POST', + headers: { + 'content-type': 'application/json', + ...bootstrapHeaders(input.bootstrapToken) + }, + body: JSON.stringify({ + source: 'configured-directory', + bootstrapToken: input.bootstrapToken + }) + }) + ); +} + export async function commitSetupCsvFiles(input: { files: File[]; previewHash: string; @@ -141,6 +180,23 @@ export async function commitSetupCsvFiles(input: { ); } +export async function commitConfiguredSetupCsvFiles(input: { + previewHash: string; + dryRun?: boolean; +}): Promise { + return readUploadResponse( + await fetch('/api/setup/import/commit', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + source: 'configured-directory', + previewHash: input.previewHash, + dryRun: Boolean(input.dryRun) + }) + }) + ); +} + export async function commitBootstrapCsvStep(input: { stepKey: string; file: File; @@ -162,9 +218,34 @@ export async function commitBootstrapCsvStep(input: { ); } -export async function completeBootstrapSetup(report: Record): Promise { +export async function commitConfiguredBootstrapCsv(input: { + previewHash: string; + dryRun?: boolean; + bootstrapToken?: string; +}): Promise { + return readUploadResponse( + await fetch('/api/setup/bootstrap/commit', { + method: 'POST', + headers: { + 'content-type': 'application/json', + ...bootstrapHeaders(input.bootstrapToken) + }, + body: JSON.stringify({ + source: 'configured-directory', + previewHash: input.previewHash, + dryRun: Boolean(input.dryRun), + bootstrapToken: input.bootstrapToken + }) + }) + ); +} + +export async function completeBootstrapSetup(input: CompleteBootstrapSetupPayload): Promise { return apiClient('/setup/bootstrap/complete', { method: 'POST', - body: JSON.stringify({ report }) + body: JSON.stringify({ + bootstrapToken: input.bootstrapToken, + report: input.report ?? {} + }) }); } diff --git a/src/features/setup/api/types.ts b/src/features/setup/api/types.ts index 52e96f1..9f619ec 100644 --- a/src/features/setup/api/types.ts +++ b/src/features/setup/api/types.ts @@ -1,4 +1,4 @@ -export type SetupCheckStatus = 'PASS' | 'WARNING' | 'FAIL'; +export type SetupCheckStatus = "PASS" | "WARNING" | "FAIL"; export interface SetupCheckResult { id: string; @@ -89,6 +89,34 @@ export interface SetupTemplateMetadataItem { visible?: boolean; } +export interface SetupImportSourceFileDto { + fileName: string; + kind: 'required' | 'optional'; + bytes: number; + modifiedAt: string; + checksum: string; + headers: string[]; +} + +export interface SetupImportIgnoredFileDto { + fileName: string; + reason: string; +} + +export interface SetupImportSourceDto { + envKey: string; + exampleValue: string; + configuredValue: string | null; + configured: boolean; + resolvedPath: string | null; + exists: boolean; + files: SetupImportSourceFileDto[]; + missingRequiredFiles: string[]; + ignoredFiles: SetupImportIgnoredFileDto[]; + errors: string[]; + warnings: string[]; +} + export interface SetupTemplatesResponse { success: boolean; time: string; @@ -119,6 +147,7 @@ export interface BootstrapSetupStatusResponse { requiredFiles: string[]; optionalFiles: string[]; requiredSteps: BootstrapRequiredStepDto[]; + importSource: SetupImportSourceDto; } export interface SetupProfilesResponse { @@ -137,29 +166,29 @@ export interface SetupProfilesResponse { } export type SetupRunStatus = - | 'not_started' - | 'readiness_checked' - | 'foundation_installed' - | 'organization_created' - | 'administrator_created' - | 'scope_configured' - | 'sequences_configured' - | 'approval_configured' - | 'csv_validated' - | 'csv_imported' - | 'verified' - | 'completed' - | 'failed' - | 'cancelled'; + | "not_started" + | "readiness_checked" + | "foundation_installed" + | "organization_created" + | "administrator_created" + | "scope_configured" + | "sequences_configured" + | "approval_configured" + | "csv_validated" + | "csv_imported" + | "verified" + | "completed" + | "failed" + | "cancelled"; export type SetupStepStatus = - | 'not_started' - | 'running' - | 'passed' - | 'warning' - | 'failed' - | 'skipped' - | 'stale'; + | "not_started" + | "running" + | "passed" + | "warning" + | "failed" + | "skipped" + | "stale"; export interface SetupRunDto { id: string; @@ -244,18 +273,23 @@ export interface SaveSetupStepPayload { warnings?: unknown[] | null; } +export interface CompleteBootstrapSetupPayload { + report?: Record; + bootstrapToken?: string; +} + export type { CsvPreviewError, CsvPreviewFileResult, CsvPreviewRow, CsvPreviewResult, CsvPreviewStatus, - CsvPreviewWarning -} from '@/features/setup/server/csv/types'; + CsvPreviewWarning, +} from "@/features/setup/server/csv/types"; export type { ImportFileReport, ImportReport, ImportRowReport, - ImportStatus -} from '@/features/setup/server/csv/import-report'; + ImportStatus, +} from "@/features/setup/server/csv/import-report"; diff --git a/src/features/setup/api/use-csv-commit.ts b/src/features/setup/api/use-csv-commit.ts index ae73696..a9aaa22 100644 --- a/src/features/setup/api/use-csv-commit.ts +++ b/src/features/setup/api/use-csv-commit.ts @@ -1,10 +1,15 @@ 'use client'; import { useMutation } from '@tanstack/react-query'; + import { commitBootstrapCsvStep, + commitConfiguredBootstrapCsv, + commitConfiguredSetupCsvFiles, commitSetupCsvFiles, previewBootstrapCsvStep, + previewConfiguredBootstrapCsv, + previewConfiguredSetupCsvFiles, previewSetupCsvFiles } from './service'; @@ -16,15 +21,25 @@ export function useCsvPreview() { export function useCsvCommit() { return useMutation({ - mutationFn: (input: { files: File[]; previewHash: string; dryRun?: boolean }) => - commitSetupCsvFiles(input) + mutationFn: (input: { files: File[]; previewHash: string; dryRun?: boolean }) => commitSetupCsvFiles(input) + }); +} + +export function useConfiguredCsvPreview() { + return useMutation({ + mutationFn: () => previewConfiguredSetupCsvFiles() + }); +} + +export function useConfiguredCsvCommit() { + return useMutation({ + mutationFn: (input: { previewHash: string; dryRun?: boolean }) => commitConfiguredSetupCsvFiles(input) }); } export function useBootstrapCsvPreview() { return useMutation({ - mutationFn: (input: { stepKey: string; file: File; bootstrapToken?: string }) => - previewBootstrapCsvStep(input) + mutationFn: (input: { stepKey: string; file: File; bootstrapToken?: string }) => previewBootstrapCsvStep(input) }); } @@ -39,3 +54,16 @@ export function useBootstrapCsvCommit() { }) => commitBootstrapCsvStep(input) }); } + +export function useConfiguredBootstrapCsvPreview() { + return useMutation({ + mutationFn: (input: { bootstrapToken?: string }) => previewConfiguredBootstrapCsv(input) + }); +} + +export function useConfiguredBootstrapCsvCommit() { + return useMutation({ + mutationFn: (input: { previewHash: string; dryRun?: boolean; bootstrapToken?: string }) => + commitConfiguredBootstrapCsv(input) + }); +} diff --git a/src/features/setup/api/use-setup-run.ts b/src/features/setup/api/use-setup-run.ts index 0096eee..66c0d67 100644 --- a/src/features/setup/api/use-setup-run.ts +++ b/src/features/setup/api/use-setup-run.ts @@ -1,16 +1,19 @@ 'use client'; import { useMutation, useQueryClient } from '@tanstack/react-query'; + import { completeBootstrapSetup, completeSetupRun, saveSetupStep, startSetupRun } from './service'; import { setupKeys } from './queries'; -import type { SaveSetupStepPayload } from './types'; +import type { CompleteBootstrapSetupPayload, SaveSetupStepPayload } from './types'; function useInvalidateSetupRun() { const queryClient = useQueryClient(); return () => { queryClient.invalidateQueries({ queryKey: setupKeys.run() }); + queryClient.invalidateQueries({ queryKey: [...setupKeys.all, 'bootstrap'] }); queryClient.invalidateQueries({ queryKey: setupKeys.report() }); + queryClient.invalidateQueries({ queryKey: setupKeys.bootstrapStatus() }); }; } @@ -45,7 +48,7 @@ export function useCompleteBootstrapSetup() { const invalidate = useInvalidateSetupRun(); return useMutation({ - mutationFn: (report: Record) => completeBootstrapSetup(report), + mutationFn: (input: CompleteBootstrapSetupPayload) => completeBootstrapSetup(input), onSuccess: invalidate }); } diff --git a/src/features/setup/components/setup-wizard.tsx b/src/features/setup/components/setup-wizard.tsx index 78d5958..5027bd3 100644 --- a/src/features/setup/components/setup-wizard.tsx +++ b/src/features/setup/components/setup-wizard.tsx @@ -2,6 +2,8 @@ import { useMemo, useState } from 'react'; import { useQuery } from '@tanstack/react-query'; +import { toast } from 'sonner'; + import { Icons } from '@/components/icons'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Badge } from '@/components/ui/badge'; @@ -10,9 +12,15 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com import { Checkbox } from '@/components/ui/checkbox'; import { Input } from '@/components/ui/input'; import { cn } from '@/lib/utils'; -import { useBootstrapCsvCommit, useBootstrapCsvPreview } from '../api/use-csv-commit'; -import { useCompleteBootstrapSetup } from '../api/use-setup-run'; import { + useConfiguredBootstrapCsvCommit, + useConfiguredBootstrapCsvPreview, + useConfiguredCsvCommit, + useConfiguredCsvPreview +} from '../api/use-csv-commit'; +import { useCompleteBootstrapSetup, useCompleteSetupRun } from '../api/use-setup-run'; +import { + bootstrapSetupRunQueryOptions, bootstrapSetupStatusQueryOptions, setupRunQueryOptions, setupTemplatesQueryOptions @@ -24,18 +32,14 @@ import type { CsvPreviewResult, ImportReport, SetupCheckStatus, + SetupImportSourceDto, + SetupImportSourceFileDto, SetupRunStepDto, SetupStepStatus, SetupTemplateMetadataItem } from '../api/types'; -const BUSINESS_IMPORT_FILES = [ - 'customers.csv', - 'contacts.csv', - 'leads.csv', - 'opportunities.csv', - 'quotations.csv' -]; +const SAMPLE_ROW_LIMIT = 3; function getErrorMessage(error: unknown): string { if (error instanceof Error) return error.message; @@ -45,9 +49,7 @@ function getErrorMessage(error: unknown): string { function getPreviewStatus(preview: CsvPreviewResult | null): SetupCheckStatus | null { if (!preview) return null; if (preview.summary.error > 0 || preview.files.some((file) => file.status === 'FAIL')) return 'FAIL'; - if (preview.summary.warning > 0 || preview.files.some((file) => file.status === 'WARNING')) { - return 'WARNING'; - } + if (preview.summary.warning > 0 || preview.files.some((file) => file.status === 'WARNING')) return 'WARNING'; return 'PASS'; } @@ -55,19 +57,14 @@ function isStepComplete(step?: SetupRunStepDto): boolean { return step?.status === 'passed' || step?.status === 'warning'; } -function setupStatusFromReport(report: ImportReport): SetupStepStatus { - if (report.status === 'FAIL') return 'failed'; - if (report.status === 'WARNING') return 'warning'; - return 'passed'; +function statusBadgeVariant(status: SetupStepStatus | SetupCheckStatus | 'found' | 'missing' | 'not_started') { + if (status === 'failed' || status === 'FAIL' || status === 'missing') return 'destructive'; + if (status === 'warning' || status === 'WARNING' || status === 'stale') return 'secondary'; + if (status === 'passed' || status === 'PASS' || status === 'found') return 'default'; + return 'outline'; } -function statusBadgeVariant(status: SetupStepStatus | SetupCheckStatus | 'locked' | 'not_started') { - if (status === 'FAIL' || status === 'failed') return 'destructive'; - if (status === 'PASS' || status === 'passed') return 'default'; - return 'secondary'; -} - -function StepStatusBadge({ status }: { status: SetupStepStatus | SetupCheckStatus | 'locked' | 'not_started' }) { +function StepStatusBadge({ status }: { status: SetupStepStatus | SetupCheckStatus | 'found' | 'missing' | 'not_started' }) { return {status}; } @@ -114,57 +111,13 @@ function CsvErrors({ errors }: { errors: CsvPreviewError[] }) { ); } -function PreviewFilePanel({ file }: { file: CsvPreviewFileResult }) { +function LoadingAlert({ phase }: { phase: string }) { return ( -
-
-
-
{file.fileName}
-
Template: {file.template}
-
- -
-
- - - - - -
-
- -
-
- ); -} - -function ImportReportPanel({ report }: { report: ImportReport | null }) { - if (!report) { - return ( -
- Import has not run for this step yet. -
- ); - } - - return ( -
-
-
-
Import result
-
Committed hash: {report.committedHash}
-
- -
-
- - - - - -
- -
+ + + Working + {phase} + ); } @@ -172,169 +125,419 @@ function Stepper({ steps, currentStepKey, stepByKey, + fileByName, + previewByFileName, onSelect }: { steps: BootstrapRequiredStepDto[]; currentStepKey: string; stepByKey: Map; + fileByName: Map; + previewByFileName: Map; onSelect: (stepKey: string) => void; }) { return ( System Required - Import required setup CSV files in dependency order. + Click a row to inspect the CSV file detail. {steps.map((step, index) => { const persisted = stepByKey.get(step.stepKey); - const locked = step.dependsOn.some((dependency) => !isStepComplete(stepByKey.get(dependency))); + const sourceFile = fileByName.get(step.fileName.toLowerCase()); + const previewFile = previewByFileName.get(step.fileName.toLowerCase()); + const selected = currentStepKey === step.stepKey; + return ( ); })} - -
-
Optional Business Data
-
- {BUSINESS_IMPORT_FILES.map((fileName) => ( -
- {fileName.replace('.csv', '')} - later -
- ))} -
-
); } -export function SetupWizard() { +function SourcePanel({ source }: { source?: SetupImportSourceDto }) { + if (!source) { + return ( + + + Configured Import Folder + Loading setup import source. + + + ); + } + + return ( + + +
+
+ Configured Import Folder + + Configure CSV source with {source.envKey} in .env, then restart the server. + +
+ 0 ? 'destructive' : source.warnings.length > 0 ? 'secondary' : 'default'}> + {source.errors.length > 0 ? 'Not ready' : source.warnings.length > 0 ? 'Warning' : 'Ready'} + +
+
+ +
+
+
Env key
+
{source.envKey}
+
+
+
Configured value
+
{source.configuredValue ?? 'Not configured'}
+
+
+
Resolved path
+
{source.resolvedPath ?? 'Unavailable'}
+
+
+ + {source.errors.length > 0 ? ( + + + Import folder requires configuration + +
+ {source.errors.map((error) => ( +
{error}
+ ))} + + {source.envKey}={source.exampleValue} + +
+
+
+ ) : null} +
+
+ ); +} + +function SelectedFileDetail({ + step, + sourceFile, + template, + previewFile +}: { + step: BootstrapRequiredStepDto; + sourceFile?: SetupImportSourceFileDto; + template?: SetupTemplateMetadataItem; + previewFile?: CsvPreviewFileResult; +}) { + const sampleRows = previewFile?.rows.slice(0, SAMPLE_ROW_LIMIT) ?? []; + const sampleColumns = sampleRows[0] ? Object.keys(sampleRows[0].values) : []; + + return ( + + +
+
+ {step.title} + {step.fileName} +
+ +
+
+ + {!sourceFile ? ( + + + File missing + Add {step.fileName} to the configured setup import folder. + + ) : ( + <> +
+ + + +
+
+
Modified
+
{sourceFile.modifiedAt}
+
Checksum
+
{sourceFile.checksum}
+
+
+
Columns
+
+ {sourceFile.headers.map((header) => ( + + {header} + + ))} +
+
+ + )} + +
+
Required headers
+
+ {(template?.requiredColumns ?? []).map((column) => ( + + {column} + + ))} +
+
+ + {previewFile ? ( +
+
+ + + + + + + +
+ {sampleRows.length > 0 ? ( +
+ + + + + + {sampleColumns.map((column) => ( + + ))} + + + + {sampleRows.map((row) => ( + + + + {sampleColumns.map((column) => ( + + ))} + + ))} + +
RowAction + {column} +
{row.row}{row.action} + {row.values[column] || '-'} +
+
+ ) : null} + +
+ ) : ( +
+ Click Execute Import Seed Data to preview and import the configured CSV files. +
+ )} +
+
+ ); +} + +function ImportReportPanel({ report }: { report: ImportReport | null }) { + if (!report) return null; + + return ( + + {report.status === 'FAIL' ? : } + {report.status === 'FAIL' ? 'Import failed and was rolled back' : 'Import completed successfully'} + + Imported {report.summary.imported}, updated {report.summary.updated}, skipped {report.summary.skipped}, warnings{' '} + {report.summary.warnings}, errors {report.summary.errors}. + + + ); +} + +interface SetupWizardProps { + mode?: 'admin' | 'public'; + tokenRequired?: boolean; +} + +export function SetupWizard({ mode = 'admin', tokenRequired = false }: SetupWizardProps) { const [currentStepKey, setCurrentStepKey] = useState(''); - const [selectedFile, setSelectedFile] = useState(null); const [preview, setPreview] = useState(null); const [importReport, setImportReport] = useState(null); const [warningAcknowledged, setWarningAcknowledged] = useState(false); + const [bootstrapToken, setBootstrapToken] = useState(''); + const [workingMessage, setWorkingMessage] = useState(null); + const bootstrapTokenReady = mode !== 'public' || !tokenRequired || bootstrapToken.trim().length > 0; const bootstrapStatusQuery = useQuery(bootstrapSetupStatusQueryOptions()); const templatesQuery = useQuery(setupTemplatesQueryOptions('production')); - const setupRunQuery = useQuery(setupRunQueryOptions()); - const previewMutation = useBootstrapCsvPreview(); - const commitMutation = useBootstrapCsvCommit(); - const completeMutation = useCompleteBootstrapSetup(); + const adminSetupRunQuery = useQuery({ ...setupRunQueryOptions(), enabled: mode === 'admin' }); + const publicSetupRunQuery = useQuery({ + ...bootstrapSetupRunQueryOptions(bootstrapToken.trim() || undefined), + enabled: mode === 'public' && bootstrapTokenReady + }); + const setupRunQuery = mode === 'public' ? publicSetupRunQuery : adminSetupRunQuery; + + const adminPreviewMutation = useConfiguredCsvPreview(); + const adminCommitMutation = useConfiguredCsvCommit(); + const bootstrapPreviewMutation = useConfiguredBootstrapCsvPreview(); + const bootstrapCommitMutation = useConfiguredBootstrapCsvCommit(); + const completeBootstrapMutation = useCompleteBootstrapSetup(); + const completeAdminMutation = useCompleteSetupRun(); + + const previewMutation = mode === 'public' ? bootstrapPreviewMutation : adminPreviewMutation; + const commitMutation = mode === 'public' ? bootstrapCommitMutation : adminCommitMutation; + const completeMutation = mode === 'public' ? completeBootstrapMutation : completeAdminMutation; const steps = bootstrapStatusQuery.data?.requiredSteps ?? []; + const importSource = bootstrapStatusQuery.data?.importSource; + const sourceReady = Boolean(importSource && importSource.errors.length === 0); + const previewStatus = getPreviewStatus(preview); + const isWorking = previewMutation.isPending || commitMutation.isPending || completeMutation.isPending || Boolean(workingMessage); + const stepByKey = useMemo(() => { const entries = setupRunQuery.data?.steps.map((step) => [step.stepKey, step] as const) ?? []; return new Map(entries); }, [setupRunQuery.data?.steps]); + + const fileByName = useMemo(() => { + const entries = importSource?.files.map((file) => [file.fileName.toLowerCase(), file] as const) ?? []; + return new Map(entries); + }, [importSource?.files]); + + const previewByFileName = useMemo(() => { + const entries = preview?.files.map((file) => [file.fileName.toLowerCase(), file] as const) ?? []; + return new Map(entries); + }, [preview?.files]); + const templateByFile = useMemo(() => { const entries = templatesQuery.data?.templates.map((template) => [template.file, template] as const) ?? []; return new Map(entries); }, [templatesQuery.data?.templates]); + const currentStep = steps.find((step) => step.stepKey === currentStepKey) ?? steps[0] ?? null; - const currentTemplate = currentStep ? templateByFile.get(currentStep.fileName) : null; - const currentPersistedStep = currentStep ? stepByKey.get(currentStep.stepKey) : undefined; - const previewStatus = getPreviewStatus(preview); - const dependenciesComplete = - currentStep?.dependsOn.every((dependency) => isStepComplete(stepByKey.get(dependency))) ?? false; - const allRequiredComplete = steps.every((step) => isStepComplete(stepByKey.get(step.stepKey))); - const selectedFileMatches = selectedFile?.name.toLowerCase() === currentStep?.fileName.toLowerCase(); - const importBlocked = - !currentStep || - !selectedFile || - !preview || - !preview.previewHash || - !selectedFileMatches || - previewStatus === 'FAIL' || - (previewStatus === 'WARNING' && !warningAcknowledged); + const selectedSourceFile = currentStep ? fileByName.get(currentStep.fileName.toLowerCase()) : undefined; + const selectedPreviewFile = currentStep ? previewByFileName.get(currentStep.fileName.toLowerCase()) : undefined; + const selectedTemplate = currentStep ? templateByFile.get(currentStep.fileName) : undefined; + const persistedComplete = steps.length > 0 && steps.every((step) => isStepComplete(stepByKey.get(step.stepKey))); + const importSucceeded = importReport?.status === 'PASS' || importReport?.status === 'WARNING'; + const allRequiredComplete = persistedComplete || importSucceeded; + const submitDisabled = !bootstrapTokenReady || !sourceReady || isWorking; - function selectStep(stepKey: string) { - setCurrentStepKey(stepKey); - setSelectedFile(null); - setPreview(null); - setImportReport(null); - setWarningAcknowledged(false); - } + async function runPreviewOnly() { + const result = + mode === 'public' + ? await bootstrapPreviewMutation.mutateAsync({ bootstrapToken: bootstrapToken.trim() || undefined }) + : await adminPreviewMutation.mutateAsync(); - function handleFile(fileList: FileList | null) { - setSelectedFile(fileList?.[0] ?? null); - setPreview(null); - setImportReport(null); - setWarningAcknowledged(false); - } - - async function runPreview() { - if (!currentStep || !selectedFile) return; - const result = await previewMutation.mutateAsync({ - stepKey: currentStep.stepKey, - file: selectedFile - }); setPreview(result); setImportReport(null); - await setupRunQuery.refetch(); + await Promise.all([setupRunQuery.refetch(), bootstrapStatusQuery.refetch()]); + return result; } - async function runImport() { - if (!currentStep || !selectedFile || !preview?.previewHash) return; - const result = await commitMutation.mutateAsync({ - stepKey: currentStep.stepKey, - file: selectedFile, - previewHash: preview.previewHash - }); - setImportReport(result); - await setupRunQuery.refetch(); + async function executeImportSeedData() { + setWorkingMessage('Previewing setup CSV files from the configured folder...'); + setImportReport(null); - if (setupStatusFromReport(result) !== 'failed') { - const currentIndex = steps.findIndex((step) => step.stepKey === currentStep.stepKey); - const nextStep = steps[currentIndex + 1]; - if (nextStep) selectStep(nextStep.stepKey); + try { + const previewResult = await runPreviewOnly(); + const status = getPreviewStatus(previewResult); + + if (status === 'FAIL') { + toast.error('Import blocked because preview has errors. No data was imported.'); + return; + } + + if (status === 'WARNING' && !warningAcknowledged) { + toast.error('Acknowledge preview warnings before importing seed data.'); + return; + } + + setWorkingMessage('Importing seed data in a transaction. Failed imports roll back automatically...'); + + const result = + mode === 'public' + ? await bootstrapCommitMutation.mutateAsync({ + previewHash: previewResult.previewHash, + bootstrapToken: bootstrapToken.trim() || undefined + }) + : await adminCommitMutation.mutateAsync({ previewHash: previewResult.previewHash }); + + setImportReport(result); + await Promise.all([setupRunQuery.refetch(), bootstrapStatusQuery.refetch()]); + + if (result.status === 'FAIL') { + toast.error('Import failed. Database changes were rolled back.'); + return; + } + + toast.success('Import seed data completed successfully.'); + } catch (error) { + toast.error(`${getErrorMessage(error)} Database changes were rolled back.`); + } finally { + setWorkingMessage(null); } } async function completeSetup() { - await completeMutation.mutateAsync({ - source: 'step-by-step-bootstrap', + if (mode === 'public') { + await completeBootstrapMutation.mutateAsync({ + bootstrapToken: bootstrapToken.trim() || undefined, + report: { + source: 'configured-directory-bootstrap', + requiredStepKeys: steps.map((step) => step.stepKey) + } + }); + window.location.assign('/auth/sign-in'); + return; + } + + await completeAdminMutation.mutateAsync({ + source: 'configured-directory-admin', requiredStepKeys: steps.map((step) => step.stepKey) }); - window.location.assign('/auth/sign-in'); } if (bootstrapStatusQuery.isLoading || templatesQuery.isLoading || setupRunQuery.isLoading) { return (
- Loading first-run setup wizard... + Loading setup import preview...
); } - if (bootstrapStatusQuery.isError) { - return ; - } - if (!currentStep) { return ( @@ -346,213 +549,108 @@ export function SetupWizard() { } return ( -
- - -
- - - Import order decision - - Users are imported before organizations because organizations.csv resolves created_by_email - against existing users. - - - - {bootstrapStatusQuery.data?.initialized ? ( - - - Setup already initialized - Public bootstrap setup is blocked after completion. - - ) : null} - - - -
-
- {currentStep.title} - {currentStep.description} -
- -
-
- - {!dependenciesComplete ? ( - - - Step locked - - Complete dependencies first: {currentStep.dependsOn.join(', ')}. - - - ) : null} - -
-
-
Required filename
-
{currentStep.fileName}
-
-
-
Purpose
-
{currentTemplate?.description ?? currentStep.description}
-
-
- - - -
-
Upload
- handleFile(event.target.files)} - /> - {selectedFile ? ( -
- {selectedFile.name} - {selectedFile.size} bytes -
- ) : null} - {selectedFile && !selectedFileMatches ? ( - - - Wrong file selected - Upload {currentStep.fileName} for this step. - - ) : null} -
- -
- - -
- - {previewMutation.isError ? : null} - {commitMutation.isError ? : null} - -
-
-
Preview result
- {previewStatus ? : null} -
- {preview ? ( - <> -
- - - - - - -
- {previewStatus === 'WARNING' ? ( -
- setWarningAcknowledged(checked === true)} - /> - -
- ) : null} - {preview.files.map((file) => ( - - ))} - - ) : ( -
- Run Preview to validate this CSV before import. -
- )} -
- - -
-
- - - - Final Verification - Setup can complete only after all system-required steps pass. - - -
- isStepComplete(stepByKey.get(step.stepKey))).length}/${steps.length}`} - /> - - -
- - {completeMutation.isError ? : null} -
-
-
-
- ); -} - -function TemplateColumns({ template }: { template?: SetupTemplateMetadataItem }) { - if (!template) { - return ( -
- Template metadata is not available for this file. -
- ); - } - - return ( -
-
-
Required headers
-
- {template.requiredColumns.map((column) => ( - - {column} - - ))} +
+
+
+
Setup Import Form
+
+ Execute import seed data from the configured folder. Failed imports roll back in the commit transaction. +
+
+
+ +
-
-
Sample template
- - setup/templates/{template.file} - + + {workingMessage ? : null} + {importReport ? : null} + + {previewStatus === 'WARNING' ? ( +
+ setWarningAcknowledged(checked === true)} + /> + +
+ ) : null} + + {mode === 'public' && tokenRequired ? ( + + + Bootstrap Token + Enter the setup token before running protected bootstrap steps. + + + setBootstrapToken(event.target.value)} + placeholder='Bootstrap setup token' + aria-label='Bootstrap setup token' + /> + + + ) : null} + +
+ + +
+ + + + + + Final Verification + Setup can complete after system-required import steps pass. + + +
+ isStepComplete(stepByKey.get(step.stepKey))).length}/${steps.length}`} + /> + + +
+ + {completeMutation.isError ? : null} +
+
+
); diff --git a/src/features/setup/server/csv/upsert-executor.ts b/src/features/setup/server/csv/upsert-executor.ts index 22c7c67..ab67869 100644 --- a/src/features/setup/server/csv/upsert-executor.ts +++ b/src/features/setup/server/csv/upsert-executor.ts @@ -1,7 +1,7 @@ import 'server-only'; import { hash } from 'bcryptjs'; -import { randomUUID } from 'node:crypto'; +import { createHash, randomUUID } from 'node:crypto'; import { and, eq, isNull, sql } from 'drizzle-orm'; import { crmApprovalMatrices, @@ -34,6 +34,25 @@ import { normalizeEmail, normalizeLower, normalizeOrganizationCode, pipeList } f type ImportTx = typeof db; +function deterministicId(input: string): string { + const digest = createHash('sha256').update(input).digest('hex'); + const part3 = `5${digest.slice(13, 16)}`; + const part4 = `a${digest.slice(17, 20)}`; + + return [ + digest.slice(0, 8), + digest.slice(8, 12), + part3, + part4, + digest.slice(20, 32) + ].join('-'); +} + +function deterministicOrganizationId(organizationCode: string): string { + const normalized = normalizeOrganizationCode(organizationCode).toLowerCase(); + return deterministicId(`organization:${normalized}`); +} + export class CsvCommitExecutionError extends Error { files: ImportFileReport[]; @@ -105,11 +124,14 @@ async function findUserId(tx: ImportTx, email: string): Promise { async function findOrganization(tx: ImportTx, organizationCode: string): Promise<{ id: string; slug: string } | null> { if (!organizationCode) return null; const normalized = normalizeOrganizationCode(organizationCode); + const deterministicId = deterministicOrganizationId(normalized); const row = await first( tx .select({ id: organizations.id, slug: organizations.slug }) .from(organizations) - .where(sql`lower(${organizations.slug}) = ${normalized.toLowerCase()} or ${organizations.id} = ${normalized}`) + .where( + sql`lower(${organizations.slug}) = ${normalized.toLowerCase()} or ${organizations.id} = ${deterministicId} or ${organizations.id} = ${normalized}` + ) .limit(1) ); return row ?? null; @@ -306,7 +328,15 @@ async function upsertOrganization(tx: ImportTx, planRow: CsvCommitPlanRow): Prom const values = planRow.row.values; const slug = normalizeLower(values.slug); const createdBy = await resolveUserId(tx, values.created_by_email); - const existing = await first(tx.select({ id: organizations.id }).from(organizations).where(eq(organizations.slug, slug)).limit(1)); + const id = deterministicOrganizationId(values.organization_code); + const legacyId = normalizeOrganizationCode(values.organization_code); + const existing = await first( + tx + .select({ id: organizations.id }) + .from(organizations) + .where(sql`${organizations.id} = ${id} or ${organizations.id} = ${legacyId} or ${organizations.slug} = ${slug}`) + .limit(1) + ); const updateValues = { name: values.organization_name, imageUrl: emptyToNull(values.logo_url), @@ -318,7 +348,6 @@ async function upsertOrganization(tx: ImportTx, planRow: CsvCommitPlanRow): Prom await tx.update(organizations).set(updateValues).where(eq(organizations.id, existing.id)); return { action: 'update', entityId: existing.id }; } - const id = normalizeOrganizationCode(values.organization_code); await tx.insert(organizations).values({ id, slug, ...updateValues }); return { action: 'create', entityId: id }; } diff --git a/src/features/setup/server/setup-bootstrap.service.ts b/src/features/setup/server/setup-bootstrap.service.ts index 89ee856..01cfaa7 100644 --- a/src/features/setup/server/setup-bootstrap.service.ts +++ b/src/features/setup/server/setup-bootstrap.service.ts @@ -5,6 +5,7 @@ import { memberships, organizations, setupRuns, users } from '@/db/schema'; import { db } from '@/lib/db'; import type { ImportReport } from './csv/import-report'; import type { CsvPreviewFileInput, CsvPreviewResult } from './csv/types'; +import { readConfiguredSetupImportSource, type SetupImportSourceReport } from './setup-import-source.service'; import type { SetupStepStatus } from './setup-state.service'; export const BOOTSTRAP_ACTOR_ID = 'bootstrap-setup'; @@ -137,6 +138,66 @@ export class BootstrapSetupError extends Error { } } +const SENSITIVE_COLUMN_PATTERN = /(password|password_hash|token|secret)/i; + +function maskSensitiveValues(values: Record) { + return Object.fromEntries( + Object.entries(values).map(([key, value]) => [ + key, + SENSITIVE_COLUMN_PATTERN.test(key) && value ? '********' : value + ]) + ); +} + +export function sanitizeCsvPreviewForResponse(preview: CsvPreviewResult): CsvPreviewResult { + return { + ...preview, + files: preview.files.map((file) => ({ + ...file, + rows: file.rows.slice(0, 5).map((row) => ({ + ...row, + values: maskSensitiveValues(row.values) + })) + })) + }; +} + +export function sanitizeCsvPreviewForPersistence(preview: CsvPreviewResult) { + return { + summary: preview.summary, + generatedAt: preview.generatedAt, + previewHash: preview.previewHash, + files: preview.files.map((file) => ({ + fileName: file.fileName, + template: file.template, + importOrder: file.importOrder, + status: file.status, + summary: file.summary, + errors: file.errors, + warnings: file.warnings + })) + }; +} + +export function sanitizeCsvPreviewFileForPersistence(preview: CsvPreviewResult, fileName: string) { + const file = preview.files.find((candidate) => candidate.fileName.toLowerCase() === fileName.toLowerCase()); + if (!file) return sanitizeCsvPreviewForPersistence(preview); + return { + summary: file.summary, + generatedAt: preview.generatedAt, + previewHash: preview.previewHash, + file: { + fileName: file.fileName, + template: file.template, + importOrder: file.importOrder, + status: file.status, + summary: file.summary, + errors: file.errors, + warnings: file.warnings + } + }; +} + export interface BootstrapSetupStatus { initialized: boolean; tokenRequired: boolean; @@ -149,6 +210,7 @@ export interface BootstrapSetupStatus { requiredFiles: string[]; optionalFiles: string[]; requiredSteps: typeof BOOTSTRAP_REQUIRED_STEPS; + importSource: SetupImportSourceReport; } async function hasRows(query: Promise>) { @@ -161,7 +223,7 @@ async function hasRows(query: Promise>) { } export async function getBootstrapSetupStatus(): Promise { - const [completedSetupRun, superAdminUser, organization, adminMembership] = await Promise.all([ + const [completedSetupRun, superAdminUser, organization, adminMembership, importSource] = await Promise.all([ hasRows( db .select({ value: count() }) @@ -180,7 +242,11 @@ export async function getBootstrapSetupStatus(): Promise { .select({ value: count() }) .from(memberships) .where(eq(memberships.role, 'admin')) - ) + ), + readConfiguredSetupImportSource({ + requiredFiles: BOOTSTRAP_REQUIRED_TEMPLATE_FILES, + optionalFiles: BOOTSTRAP_OPTIONAL_TEMPLATE_FILES + }).then((result) => result.report) ]); const checks = { @@ -196,7 +262,8 @@ export async function getBootstrapSetupStatus(): Promise { checks, requiredFiles: [...BOOTSTRAP_REQUIRED_TEMPLATE_FILES], optionalFiles: [...BOOTSTRAP_OPTIONAL_TEMPLATE_FILES], - requiredSteps: [...BOOTSTRAP_REQUIRED_STEPS] + requiredSteps: [...BOOTSTRAP_REQUIRED_STEPS], + importSource }; } diff --git a/src/features/setup/server/setup-import-source.service.ts b/src/features/setup/server/setup-import-source.service.ts new file mode 100644 index 0000000..d8a42d7 --- /dev/null +++ b/src/features/setup/server/setup-import-source.service.ts @@ -0,0 +1,183 @@ +import 'server-only'; + +import { createHash } from 'node:crypto'; +import { readdir, readFile, stat } from 'node:fs/promises'; +import path from 'node:path'; + +import { getCsvTemplateRegistry } from './csv/template-registry'; +import type { CsvPreviewFileInput } from './csv/types'; + +const SETUP_IMPORT_ENV_KEY = 'SETUP_IMPORT_DIR'; +const DEFAULT_SETUP_IMPORT_DIR = './setup/ALLA-ONVALLA'; + +export interface SetupImportSourceFile { + fileName: string; + kind: 'required' | 'optional'; + bytes: number; + modifiedAt: string; + checksum: string; + headers: string[]; +} + +export interface SetupImportIgnoredFile { + fileName: string; + reason: string; +} + +export interface SetupImportSourceReport { + envKey: typeof SETUP_IMPORT_ENV_KEY; + exampleValue: typeof DEFAULT_SETUP_IMPORT_DIR; + configuredValue: string | null; + configured: boolean; + resolvedPath: string | null; + exists: boolean; + files: SetupImportSourceFile[]; + missingRequiredFiles: string[]; + ignoredFiles: SetupImportIgnoredFile[]; + errors: string[]; + warnings: string[]; +} + +export interface SetupImportSourceReadResult { + report: SetupImportSourceReport; + files: CsvPreviewFileInput[]; +} + +function sha256(bytes: Uint8Array) { + return `sha256:${createHash('sha256').update(bytes).digest('hex')}`; +} + +function readCsvHeaders(bytes: Uint8Array) { + const text = new TextDecoder('utf-8', { fatal: false }) + .decode(bytes) + .replace(/^\uFEFF/, ''); + const [header = ''] = text.split(/\r?\n/, 1); + return header.split(',').map((column) => column.trim()).filter(Boolean); +} + +function resolveConfiguredPath(configuredValue: string) { + return path.isAbsolute(configuredValue) + ? path.normalize(configuredValue) + : path.resolve(process.cwd(), configuredValue); +} + +function createBaseReport(configuredValue: string | null): SetupImportSourceReport { + return { + envKey: SETUP_IMPORT_ENV_KEY, + exampleValue: DEFAULT_SETUP_IMPORT_DIR, + configuredValue, + configured: Boolean(configuredValue), + resolvedPath: configuredValue ? resolveConfiguredPath(configuredValue) : null, + exists: false, + files: [], + missingRequiredFiles: [], + ignoredFiles: [], + errors: [], + warnings: [] + }; +} + +export async function readConfiguredSetupImportSource(input: { + requiredFiles: readonly string[]; + optionalFiles: readonly string[]; +}): Promise { + const configuredValue = process.env[SETUP_IMPORT_ENV_KEY]?.trim() || null; + const report = createBaseReport(configuredValue); + + if (!configuredValue || !report.resolvedPath) { + report.errors.push( + `Add ${SETUP_IMPORT_ENV_KEY}=${DEFAULT_SETUP_IMPORT_DIR} to .env and restart the server.` + ); + report.missingRequiredFiles = [...input.requiredFiles]; + return { report, files: [] }; + } + + const registry = await getCsvTemplateRegistry(); + const supportedFiles = new Map( + registry.templates + .filter((template) => template.supported) + .map((template) => [template.file.toLowerCase(), template]) + ); + const requiredFiles = new Set(input.requiredFiles.map((fileName) => fileName.toLowerCase())); + const discovered = new Set(); + const files: CsvPreviewFileInput[] = []; + + try { + const directoryStat = await stat(report.resolvedPath); + if (!directoryStat.isDirectory()) { + report.errors.push(`${report.resolvedPath} is not a directory.`); + report.missingRequiredFiles = [...input.requiredFiles]; + return { report, files: [] }; + } + + report.exists = true; + const entries = await readdir(report.resolvedPath, { withFileTypes: true }); + + for (const entry of entries) { + if (!entry.isFile()) { + continue; + } + + const fileName = path.basename(entry.name); + const normalized = fileName.toLowerCase(); + const template = supportedFiles.get(normalized); + + if (!template) { + report.ignoredFiles.push({ + fileName, + reason: 'File is not a registered supported setup CSV template.' + }); + continue; + } + + const filePath = path.join(report.resolvedPath, fileName); + const [fileStat, bytes] = await Promise.all([stat(filePath), readFile(filePath)]); + const byteArray = new Uint8Array(bytes); + const kind = requiredFiles.has(normalized) ? 'required' : 'optional'; + + discovered.add(normalized); + files.push({ fileName: template.file, bytes: byteArray }); + report.files.push({ + fileName: template.file, + kind, + bytes: byteArray.byteLength, + modifiedAt: fileStat.mtime.toISOString(), + checksum: sha256(byteArray), + headers: readCsvHeaders(byteArray) + }); + } + } catch (error) { + report.errors.push( + error instanceof Error + ? `Unable to read ${report.resolvedPath}: ${error.message}` + : `Unable to read ${report.resolvedPath}.` + ); + } + + report.missingRequiredFiles = input.requiredFiles.filter( + (fileName) => !discovered.has(fileName.toLowerCase()) + ); + + if (report.missingRequiredFiles.length > 0) { + report.errors.push( + `Missing required setup CSV files: ${report.missingRequiredFiles.join(', ')}.` + ); + } + + if (report.ignoredFiles.length > 0) { + report.warnings.push('Some files in the setup import directory are ignored.'); + } + + files.sort((left, right) => { + const leftTemplate = supportedFiles.get(left.fileName.toLowerCase()); + const rightTemplate = supportedFiles.get(right.fileName.toLowerCase()); + return (leftTemplate?.importOrder ?? 0) - (rightTemplate?.importOrder ?? 0); + }); + report.files.sort((left, right) => { + const leftTemplate = supportedFiles.get(left.fileName.toLowerCase()); + const rightTemplate = supportedFiles.get(right.fileName.toLowerCase()); + return (leftTemplate?.importOrder ?? 0) - (rightTemplate?.importOrder ?? 0); + }); + + return { report, files }; +} diff --git a/src/features/setup/server/setup-state.service.ts b/src/features/setup/server/setup-state.service.ts index e6e9e6a..24895c7 100644 --- a/src/features/setup/server/setup-state.service.ts +++ b/src/features/setup/server/setup-state.service.ts @@ -39,6 +39,7 @@ export interface SaveSetupStepInput { output?: Record | null; errors?: unknown[] | null; warnings?: unknown[] | null; + skipStaleInvalidation?: boolean; } const ACTIVE_RUN_STATUSES: SetupRunStatus[] = [ @@ -319,7 +320,7 @@ export async function saveSetupStep(input: SaveSetupStepInput) { } }); - if (inputChanged) { + if (inputChanged && !input.skipStaleInvalidation) { const staleSteps = STALE_DEPENDENCIES[input.stepKey] ?? []; await Promise.all( staleSteps.map((stepKey) => diff --git a/src/tests/setup/helpers.ts b/src/tests/setup/helpers.ts index 55fba39..6f6def5 100644 --- a/src/tests/setup/helpers.ts +++ b/src/tests/setup/helpers.ts @@ -26,7 +26,7 @@ export function assertFileExists(relativePath: string) { export function parseCsv(relativePath: string): CsvFile { const content = readText(relativePath).trim(); - const [headerLine, ...rowLines] = content.split(/\r?\n/); + const [headerLine = '', ...rowLines] = content.split(/\r?\n/); return { header: headerLine.split(','), rows: rowLines.filter(Boolean).map((line) => line.split(',')) diff --git a/src/tests/setup/setup-bootstrap.test.ts b/src/tests/setup/setup-bootstrap.test.ts index 565ec38..682f889 100644 --- a/src/tests/setup/setup-bootstrap.test.ts +++ b/src/tests/setup/setup-bootstrap.test.ts @@ -1,5 +1,6 @@ import assert from 'node:assert/strict'; import test from 'node:test'; + import { assertFileExists, readText } from './helpers.ts'; test('first-run bootstrap setup public surfaces exist', () => { @@ -8,8 +9,9 @@ test('first-run bootstrap setup public surfaces exist', () => { } assertFileExists('src/app/administration/setup/page.tsx'); - assertFileExists('src/features/setup/components/bootstrap-setup.tsx'); + assertFileExists('src/features/setup/components/setup-wizard.tsx'); assertFileExists('src/features/setup/server/setup-bootstrap.service.ts'); + assertFileExists('src/features/setup/server/setup-import-source.service.ts'); }); test('bootstrap setup guards public access after initialization', () => { @@ -29,7 +31,7 @@ test('bootstrap setup guards public access after initialization', () => { assert.match(proxy, /\/api\/setup\/templates/); }); -test('bootstrap setup enforces required CSV minimum and preview hash commit', () => { +test('bootstrap setup supports configured-directory preview and commit', () => { const service = readText('src/features/setup/server/setup-bootstrap.service.ts'); const commitRoute = readText('src/app/api/setup/bootstrap/commit/route.ts'); const previewRoute = readText('src/app/api/setup/bootstrap/preview/route.ts'); @@ -38,6 +40,7 @@ test('bootstrap setup enforces required CSV minimum and preview hash commit', () 'users.csv', 'organizations.csv', 'memberships.csv', + 'crm-role-assignments.csv', 'master-options.csv', 'branches.csv', 'product-types.csv', @@ -49,8 +52,16 @@ test('bootstrap setup enforces required CSV minimum and preview hash commit', () assert.match(service, new RegExp(fileName.replace('.', '\\.'))); } - assert.match(previewRoute, /assertBootstrapRequiredFiles/); + assert.match(previewRoute, /application\/json/); + assert.match(previewRoute, /configured-directory/); + assert.match(previewRoute, /readConfiguredSetupImportSource/); + assert.match(previewRoute, /saveConfiguredPreviewSteps/); + + assert.match(commitRoute, /application\/json/); + assert.match(commitRoute, /configured-directory/); assert.match(commitRoute, /previewHash/); assert.match(commitRoute, /commitSetupCsvImport/); - assert.match(commitRoute, /createCsvImportSeedManifest/); + assert.match(commitRoute, /saveConfiguredCommitSteps/); + assert.match(commitRoute, /skipStaleInvalidation: true/); + assert.match(previewRoute, /skipStaleInvalidation: true/); }); diff --git a/src/tests/setup/setup-commit.test.ts b/src/tests/setup/setup-commit.test.ts index 60b2a53..f0d6c37 100644 --- a/src/tests/setup/setup-commit.test.ts +++ b/src/tests/setup/setup-commit.test.ts @@ -1,8 +1,9 @@ import assert from 'node:assert/strict'; import test from 'node:test'; + import { assertFileExists, readText } from './helpers.ts'; -test('CSV commit API and seed manifest integration exist', () => { +test('CSV commit API seed manifest integration exist', () => { assertFileExists('src/app/api/setup/import/commit/route.ts'); assertFileExists('src/features/setup/server/seed-manifest.service.ts'); @@ -17,3 +18,12 @@ test('commit engine keeps preview hash validation contract', () => { assert.match(service, /PREVIEW_HASH_REQUIRED/); assert.match(service, /PREVIEW_HASH_MISMATCH/); }); + +test('organization CSV import uses deterministic UUID primary keys', () => { + const executor = readText('src/features/setup/server/csv/upsert-executor.ts'); + + assert.match(executor, /function deterministicOrganizationId/); + assert.match(executor, /deterministicId\(`organization:\$\{normalized\}`\)/); + assert.match(executor, /const id = deterministicOrganizationId\(values\.organization_code\)/); + assert.doesNotMatch(executor, /const id = normalizeOrganizationCode\(values\.organization_code\)/); +}); diff --git a/src/tests/setup/setup-import-source.test.ts b/src/tests/setup/setup-import-source.test.ts new file mode 100644 index 0000000..30903a0 --- /dev/null +++ b/src/tests/setup/setup-import-source.test.ts @@ -0,0 +1,66 @@ +import assert from 'node:assert/strict'; +import { existsSync } from 'node:fs'; +import path from 'node:path'; +import test from 'node:test'; + +import { assertFileExists, readJson, readText, repoRoot } from './helpers.ts'; + +interface TemplateMetadata { + templates: Array<{ file: string; supported: boolean }>; +} + +const requiredFiles = [ + 'users.csv', + 'organizations.csv', + 'memberships.csv', + 'crm-role-assignments.csv', + 'master-options.csv', + 'branches.csv', + 'product-types.csv', + 'document-sequences.csv', + 'approval-workflows.csv', + 'approval-steps.csv', + 'approval-matrix.csv' +]; + +test('setup import source service resolves env-based configured folder', () => { + const service = readText('src/features/setup/server/setup-import-source.service.ts'); + + assert.match(service, /SETUP_IMPORT_DIR/); + assert.match(service, /\.\/setup\/ALLA-ONVALLA/); + assert.match(service, /process\.cwd\(\)/); + assert.match(service, /path\.resolve/); + assert.match(service, /missingRequiredFiles/); + assert.match(service, /ignoredFiles/); + assert.match(service, /checksum/); + assert.match(service, /getCsvTemplateRegistry/); +}); + +test('sample ALLA setup folder contains required bootstrap CSV files', () => { + const folder = path.join(repoRoot, 'setup', 'ALLA-ONVALLA'); + assert.equal(existsSync(folder), true, 'setup/ALLA-ONVALLA should exist'); + + for (const fileName of requiredFiles) { + assertFileExists(`setup/ALLA-ONVALLA/${fileName}`); + } +}); + +test('configured setup import source only reads registered supported templates', () => { + const metadata = readJson('setup/templates/templates.json'); + const supportedFiles = new Set( + metadata.templates.filter((template) => template.supported).map((template) => template.file) + ); + + for (const fileName of requiredFiles) { + assert.equal(supportedFiles.has(fileName), true, `${fileName} should be a supported setup template`); + } + + const service = readText('src/features/setup/server/setup-import-source.service.ts'); + assert.match(service, /template\.supported/); + assert.match(service, /File is not a registered supported setup CSV template/); +}); + +test('environment documents setup import directory', () => { + const env = readText('.env'); + assert.match(env, /SETUP_IMPORT_DIR=\.\/setup\/ALLA-ONVALLA/); +}); diff --git a/src/tests/setup/setup-plugin-registry.test.ts b/src/tests/setup/setup-plugin-registry.test.ts index 3819c87..3a57a40 100644 --- a/src/tests/setup/setup-plugin-registry.test.ts +++ b/src/tests/setup/setup-plugin-registry.test.ts @@ -1,5 +1,6 @@ import assert from 'node:assert/strict'; import test from 'node:test'; + import { assertFileExists, readText } from './helpers.ts'; test('setup plugin registry files exist', () => { @@ -15,12 +16,13 @@ test('setup plugin registry files exist', () => { } }); -test('installation profile API and wizard profile loading exist', () => { +test('installation profile API remains available while wizard uses configured import source', () => { assertFileExists('src/app/api/setup/profiles/route.ts'); const wizard = readText('src/features/setup/components/setup-wizard.tsx'); - assert.match(wizard, /setupProfilesQueryOptions/); - assert.match(wizard, /Installation profile/); + assert.match(wizard, /setupTemplatesQueryOptions/); + assert.match(wizard, /Configured Import Folder/); + assert.match(wizard, /source\.envKey/); }); test('template API exposes plugin-aware metadata without removing legacy fields', () => { diff --git a/src/tests/setup/setup-resume.test.ts b/src/tests/setup/setup-resume.test.ts index dfae82d..0afbf36 100644 --- a/src/tests/setup/setup-resume.test.ts +++ b/src/tests/setup/setup-resume.test.ts @@ -1,18 +1,23 @@ import assert from 'node:assert/strict'; import test from 'node:test'; + import { assertFileExists, readText } from './helpers.ts'; test('setup resume APIs exist', () => { for (const route of ['current', 'start', 'steps', 'complete', 'report']) { assertFileExists(`src/app/api/setup/run/${route}/route.ts`); } + + assertFileExists('src/app/api/setup/bootstrap/run/current/route.ts'); assertFileExists('src/features/setup/server/setup-state.service.ts'); }); -test('setup wizard persists core step outputs', () => { +test('setup wizard refreshes run state after configured-folder import', () => { const wizard = readText('src/features/setup/components/setup-wizard.tsx'); - for (const stepKey of ['readiness', 'csv_preview', 'csv_commit', 'verification']) { - assert.match(wizard, new RegExp(stepKey)); - } + assert.match(wizard, /useConfiguredBootstrapCsvPreview/); + assert.match(wizard, /useConfiguredBootstrapCsvCommit/); + assert.match(wizard, /setupRunQuery\.refetch/); + assert.match(wizard, /bootstrapStatusQuery\.refetch/); + assert.match(wizard, /Mark setup completed/); });