csv setupdata

This commit is contained in:
phaichayon
2026-07-06 15:50:47 +07:00
parent 944dbc031d
commit d93f25e1ce
37 changed files with 1782 additions and 623 deletions

View File

@@ -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",

View File

@@ -5,8 +5,8 @@
{
"idx": 0,
"version": "7",
"when": 1783059567682,
"tag": "0000_dapper_klaw",
"when": 1783326039269,
"tag": "0000_classy_unicorn",
"breakpoints": true
}
]

View File

@@ -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
1 organization_code entity_type workflow_code branch_code product_type_code min_amount max_amount currency priority is_default is_active description

View File

@@ -0,0 +1 @@
organization_code,workflow_code,step_number,role_code,role_name,approval_mode,is_required,sla_hours,calendar_mode,timeout_action
1 organization_code workflow_code step_number role_code role_name approval_mode is_required sla_hours calendar_mode timeout_action

View File

@@ -0,0 +1 @@
organization_code,workflow_code,workflow_name,entity_type,description,is_system,is_active
1 organization_code workflow_code workflow_name entity_type description is_system is_active

View File

@@ -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
1 organization_code branch_code branch_name prefix address tax_id phone email sort_order is_active
2 ALLA head_office Head Office AL 933, 935, 937,939 ซอยอ่อนนุช 46 ถนนอ่อนนุช แขวงอ่อนนุช เขตสวนหลวง กรุงเทพมหานคร 10250 0107558000393 023220776 head.office@example.com 1 true
3 ONVALLA head_office Head Office ON เลขที่ 50 หมู่ 20 ตำบลลำลูกกา อำเภอลำลูกกา จังหวัดปทุมธานี 12150 0107558000393 021935380 eastern@example.com 2 true

View File

@@ -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
1 organization_code user_email crm_role_code is_primary branch_scope_mode branch_codes product_type_scope_mode product_type_codes is_active

View File

@@ -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
1 organization_code branch_code product_type_code document_type prefix period current_number padding_length format reset_policy is_active
2 ALLA head_office crane customer CUS yearly 0 4 {prefix}-{yyyy}-{running} period true
3 ALLA head_office crane lead LD yearly 0 4 {prefix}-{yyyy}-{running} period true
4 ALLA head_office crane opportunity OP yearly 0 4 {prefix}-{yyyy}-{running} period true
5 ALLA head_office crane quotation QT-AL-CR monthly 0 4 {prefix}-{yy}{MM}-{running} period true
6 ONVALLA head_office crane quotation QT-ON-CR monthly 0 4 {prefix}-{yy}{MM}-{running} period true

View File

@@ -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,{}
1 organization_code category code label value parent_category parent_code sort_order is_active metadata_json
2 ALLA crm_customer_group construction Construction 1 true {}
3 ALLA crm_customer_group industrial Industrial 2 true {}
4 ALLA crm_customer_sub_group factory Factory crm_customer_group industrial 1 true {}
5 ALLA crm_customer_sub_group warehouse Warehouse crm_customer_group industrial 2 true {}
6 ALLA crm_customer_status active Active 1 true {}
7 ALLA crm_customer_status inactive Inactive 2 true {}
8 ALLA crm_customer_status prospect Prospect 3 true {}
9 ALLA crm_customer_type end_customer End Customer 1 true {}
10 ALLA crm_customer_type contractor Contractor 2 true {}
11 ALLA crm_customer_type consultant Consultant 3 true {}
12 ALLA crm_customer_type developer Developer 4 true {}
13 ALLA crm_lead_channel call_in Call in 1 true {}
14 ALLA crm_lead_channel ecommerce E-commerce 2 true {}
15 ALLA crm_lead_channel email E-mail 3 true {}
16 ALLA crm_lead_channel line_oa Line OA 4 true {}
17 ALLA crm_lead_channel visit Visit 5 true {}
18 ALLA crm_lead_channel walk_in Walk In 6 true {}
19 ALLA crm_lead_channel facebook Facebook 7 true {}
20 ALLA crm_lead_channel website_contact Website Contact 8 true {}
21 ALLA crm_lead_channel bci BCI 9 true {}
22 ALLA crm_lead_awareness google_search Google Search / Website 1 true {}
23 ALLA crm_lead_awareness social_media Social media 2 true {}
24 ALLA crm_lead_awareness email_marketing Email Marketing 3 true {}
25 ALLA crm_lead_awareness visit Visit 4 true {}
26 ALLA crm_lead_awareness exhibition_event Exhibition / Event 5 true {}
27 ALLA crm_lead_awareness brochure_catalog Brochure / Catalog 6 true {}
28 ALLA crm_lead_awareness word_of_mouth Word of mouth 7 true {}
29 ALLA crm_lead_awareness corporate_car Corporate Car 8 true {}
30 ALLA crm_lead_awareness product_label Product Label 9 true {}
31 ALLA crm_lead_awareness re_purchasing Re-purchasing 10 true {}
32 ALLA crm_lead_awareness introducing Introducing 11 true {}
33 ALLA crm_lead_awareness bci BCI 12 true {}
34 ALLA crm_lead_priority high High 1 true {}
35 ALLA crm_lead_priority medium Medium 2 true {}
36 ALLA crm_lead_priority low Low 3 true {}
37 ALLA crm_followup_status new New 1 true {}
38 ALLA crm_followup_status contacted Contacted 2 true {}
39 ALLA crm_followup_status follow_up Follow Up 3 true {}
40 ALLA crm_followup_status closed Closed 4 true {}
41 ALLA crm_lost_reason price ราคา 1 true {}
42 ALLA crm_lost_reason delivery_time ระยะเวลาจัดส่ง 2 true {}
43 ALLA crm_lost_reason salesperson พนักงานขาย 3 true {}
44 ALLA crm_lost_reason service การบริการ 4 true {}
45 ALLA crm_lost_reason customer ลูกค้า 5 true {}
46 ALLA crm_lost_reason product_quality คุณภาพสินค้า 6 true {}
47 ALLA crm_lost_reason other อื่นๆ 7 true {}
48 ALLA crm_lost_reason transfer Transfer 8 true {}
49 ALLA crm_project_party_role billing_customer Billing Customer 1 true {}
50 ALLA crm_project_party_role end_customer End Customer 2 true {}
51 ALLA crm_project_party_role contractor Contractor 3 true {}
52 ALLA crm_project_party_role consultant Consultant 4 true {}
53 ALLA crm_project_party_role developer Developer 5 true {}
54 ALLA crm_project_party_role architect Architect 6 true {}
55 ALLA crm_quotation_type standard Standard 1 true {}
56 ALLA crm_quotation_type revision Revision 2 true {}
57 ONVALLA crm_customer_group construction Construction 1 true {}
58 ONVALLA crm_customer_group industrial Industrial 2 true {}
59 ONVALLA crm_customer_sub_group factory Factory crm_customer_group industrial 1 true {}
60 ONVALLA crm_customer_sub_group warehouse Warehouse crm_customer_group industrial 2 true {}
61 ONVALLA crm_customer_status active Active 1 true {}
62 ONVALLA crm_customer_status inactive Inactive 2 true {}
63 ONVALLA crm_customer_status prospect Prospect 3 true {}
64 ONVALLA crm_customer_type end_customer End Customer 1 true {}
65 ONVALLA crm_customer_type contractor Contractor 2 true {}
66 ONVALLA crm_customer_type consultant Consultant 3 true {}
67 ONVALLA crm_customer_type developer Developer 4 true {}
68 ONVALLA crm_lead_channel call_in Call in 1 true {}
69 ONVALLA crm_lead_channel ecommerce E-commerce 2 true {}
70 ONVALLA crm_lead_channel email E-mail 3 true {}
71 ONVALLA crm_lead_channel line_oa Line OA 4 true {}
72 ONVALLA crm_lead_channel visit Visit 5 true {}
73 ONVALLA crm_lead_channel walk_in Walk In 6 true {}
74 ONVALLA crm_lead_channel facebook Facebook 7 true {}
75 ONVALLA crm_lead_channel website_contact Website Contact 8 true {}
76 ONVALLA crm_lead_channel bci BCI 9 true {}
77 ONVALLA crm_lead_awareness google_search Google Search / Website 1 true {}
78 ONVALLA crm_lead_awareness social_media Social media 2 true {}
79 ONVALLA crm_lead_awareness email_marketing Email Marketing 3 true {}
80 ONVALLA crm_lead_awareness visit Visit 4 true {}
81 ONVALLA crm_lead_awareness exhibition_event Exhibition / Event 5 true {}
82 ONVALLA crm_lead_awareness brochure_catalog Brochure / Catalog 6 true {}
83 ONVALLA crm_lead_awareness word_of_mouth Word of mouth 7 true {}
84 ONVALLA crm_lead_awareness corporate_car Corporate Car 8 true {}
85 ONVALLA crm_lead_awareness product_label Product Label 9 true {}
86 ONVALLA crm_lead_awareness re_purchasing Re-purchasing 10 true {}
87 ONVALLA crm_lead_awareness introducing Introducing 11 true {}
88 ONVALLA crm_lead_awareness bci BCI 12 true {}
89 ONVALLA crm_lead_priority high High 1 true {}
90 ONVALLA crm_lead_priority medium Medium 2 true {}
91 ONVALLA crm_lead_priority low Low 3 true {}
92 ONVALLA crm_followup_status new New 1 true {}
93 ONVALLA crm_followup_status contacted Contacted 2 true {}
94 ONVALLA crm_followup_status follow_up Follow Up 3 true {}
95 ONVALLA crm_followup_status closed Closed 4 true {}
96 ONVALLA crm_lost_reason price ราคา 1 true {}
97 ONVALLA crm_lost_reason delivery_time ระยะเวลาจัดส่ง 2 true {}
98 ONVALLA crm_lost_reason salesperson พนักงานขาย 3 true {}
99 ONVALLA crm_lost_reason service การบริการ 4 true {}
100 ONVALLA crm_lost_reason customer ลูกค้า 5 true {}
101 ONVALLA crm_lost_reason product_quality คุณภาพสินค้า 6 true {}
102 ONVALLA crm_lost_reason other อื่นๆ 7 true {}
103 ONVALLA crm_lost_reason transfer Transfer 8 true {}
104 ONVALLA crm_project_party_role billing_customer Billing Customer 1 true {}
105 ONVALLA crm_project_party_role end_customer End Customer 2 true {}
106 ONVALLA crm_project_party_role contractor Contractor 3 true {}
107 ONVALLA crm_project_party_role consultant Consultant 4 true {}
108 ONVALLA crm_project_party_role developer Developer 5 true {}
109 ONVALLA crm_project_party_role architect Architect 6 true {}
110 ONVALLA crm_quotation_type standard Standard 1 true {}
111 ONVALLA crm_quotation_type revision Revision 2 true {}

View File

@@ -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
1 organization_code user_email membership_role business_role permissions branch_scope_codes product_type_scope_codes
2 ALLA super_admin@allaos.local admin system_admin * * *
3 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
4 ALLA ceo.uat@allaos.local user executive crm.dashboard.read|crm.approval.read head_office|eastern|northern crane|dockdoor|solar|service
5 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
6 ALLA mk.uat@allaos.local user marketing crm.lead.read|crm.lead.create head_office crane|dockdoor|solar
7 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
8 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
9 ALLA sales_crane.uat@allaos.local user sales crm.customer.read|crm.opportunity.read|crm.quotation.read eastern crane
10 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
11 ALLA sales_dock.uat@allaos.local user sales crm.customer.read|crm.opportunity.read|crm.quotation.read head_office dockdoor
12 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
13 ALLA sales_solar.uat@allaos.local user sales crm.customer.read|crm.opportunity.read|crm.quotation.read northern solar
14 ALLA sales.uat@allaos.local user sales crm.customer.read|crm.opportunity.read|crm.quotation.read head_office|eastern crane|dockdoor
15 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
16 ONVALLA ceo.onvalla@allaos.local user executive crm.dashboard.read|crm.approval.read head_office|eastern|northern crane|dockdoor|solar|service
17 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
18 ONVALLA mk.onvalla@allaos.local user marketing crm.lead.read|crm.lead.create head_office crane|dockdoor|solar
19 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
20 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
21 ONVALLA sales_crane.onvalla@allaos.local user sales crm.customer.read|crm.opportunity.read|crm.quotation.read eastern crane
22 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
23 ONVALLA sales_dock.onvalla@allaos.local user sales crm.customer.read|crm.opportunity.read|crm.quotation.read head_office dockdoor
24 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
25 ONVALLA sales_solar.onvalla@allaos.local user sales crm.customer.read|crm.opportunity.read|crm.quotation.read northern solar
26 ONVALLA sales.onvalla@allaos.local user sales crm.customer.read|crm.opportunity.read|crm.quotation.read head_office|eastern crane|dockdoor

View File

@@ -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
1 organization_code organization_name slug created_by_email tax_id default_currency phone email address logo_url plan status
2 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
3 ONVALLA ONVALLA Demo Company onvalla super_admin@allaos.local 1200101745178 THB 02-000-0099 admin@onvalla.example 88/8 Demo Road Bangkok enterprise active

View File

@@ -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,{}
1 organization_code product_type_code product_type_name document_prefix sort_order is_active metadata_json
2 ALLA crane Crane CR 1 true {}
3 ALLA dockdoor DocK Door DK 2 true {}
4 ONVALLA crane Crane CR 3 true {}
5 ONVALLA solarcell Solar cell SC 4 true {}

View File

@@ -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
1 email name system_role password active_organization_code image_url reset_password
2 super_admin@allaos.local System Administrator super_admin ChangeMe123! ALLA FALSE
3 gm.uat@allaos.local General Manager user ChangeMe123! ALLA FALSE
4 ceo.uat@allaos.local Chief Executive Officer user ChangeMe123! ALLA FALSE
5 head_mk.uat@allaos.local Head of Marketing user ChangeMe123! ALLA FALSE
6 mk.uat@allaos.local Marketing User user ChangeMe123! ALLA FALSE
7 manager.uat@allaos.local Sales Director user ChangeMe123! ALLA FALSE
8 mgr_crane.uat@allaos.local Crane Sales Manager user ChangeMe123! ALLA FALSE
9 sales_crane.uat@allaos.local Crane Sales user ChangeMe123! ALLA FALSE
10 mgr_dock.uat@allaos.local Dock Door Sales Manager user ChangeMe123! ALLA FALSE
11 sales_dock.uat@allaos.local Dock Door Sales user ChangeMe123! ALLA FALSE
12 mgr_solar.uat@allaos.local Solar Sales Manager user ChangeMe123! ALLA FALSE
13 sales_solar.uat@allaos.local Solar Sales user ChangeMe123! ALLA FALSE
14 sales.uat@allaos.local General Sales user ChangeMe123! ALLA FALSE
15 gm.onvalla@allaos.local General Manager user ChangeMe123! ONVALLA FALSE
16 ceo.onvalla@allaos.local Chief Executive Officer user ChangeMe123! ONVALLA FALSE
17 head_mk.onvalla@allaos.local Head of Marketing user ChangeMe123! ONVALLA FALSE
18 mk.onvalla@allaos.local Marketing User user ChangeMe123! ONVALLA FALSE
19 manager.onvalla@allaos.local Sales Director user ChangeMe123! ONVALLA FALSE
20 mgr_crane.onvalla@allaos.local Crane Sales Manager user ChangeMe123! ONVALLA FALSE
21 sales_crane.onvalla@allaos.local Crane Sales user ChangeMe123! ONVALLA FALSE
22 mgr_dock.onvalla@allaos.local Dock Door Sales Manager user ChangeMe123! ONVALLA FALSE
23 sales_dock.onvalla@allaos.local Dock Door Sales user ChangeMe123! ONVALLA FALSE
24 mgr_solar.onvalla@allaos.local Solar Sales Manager user ChangeMe123! ONVALLA FALSE
25 sales_solar.onvalla@allaos.local Solar Sales user ChangeMe123! ONVALLA FALSE
26 sales.onvalla@allaos.local General Sales user ChangeMe123! ONVALLA FALSE

View File

@@ -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 (
<BootstrapSetup
tokenRequired={status.tokenRequired}
requiredFiles={status.requiredFiles}
optionalFiles={status.optionalFiles}
/>
<main className="min-h-screen bg-background py-8 px-4">
<div className="flex w-full flex-col gap-6">
<div className="space-y-2">
<h1 className="text-3xl font-semibold tracking-normal">
ALLA OS Setup
</h1>
<p className="max-w-3xl text-sm text-muted-foreground">
Preview setup CSV files from SETUP_IMPORT_DIR, run the import, then
complete setup.
</p>
</div>
<SetupWizard mode="public" tokenRequired={status.tokenRequired} />
</div>
</main>
);
}

View File

@@ -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,33 +30,120 @@ 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';
}
export async function POST(request: NextRequest) {
try {
await assertBootstrapSetupAvailable();
async function readMultipartFiles(formData: FormData): Promise<CsvPreviewFileInput[]> {
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 uploadedFiles = Array.from(formData.values()).filter(isFile);
const files = await readMultipartFiles(formData);
if (uploadedFiles.length === 0) {
return NextResponse.json({ message: 'Upload at least one setup CSV file using multipart/form-data.' }, { status: 400 });
}
const files = await Promise.all(
uploadedFiles.map(async (file) => ({
fileName: file.name,
bytes: new Uint8Array(await file.arrayBuffer())
}))
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 });
@@ -74,6 +169,17 @@ export async function POST(request: NextRequest) {
});
return NextResponse.json(report, { status: report.status === 'FAIL' ? 400 : 200 });
}
export async function POST(request: NextRequest) {
try {
await assertBootstrapSetupAvailable();
if (request.headers.get('content-type')?.includes('application/json')) {
return await commitConfiguredDirectory(request);
}
return await commitUploadedStep(request);
} catch (error) {
if (error instanceof BootstrapSetupError) {
return NextResponse.json({ message: error.message, details: error.details }, { status: error.status });

View File

@@ -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 {
);
}
export async function POST(request: NextRequest) {
try {
await assertBootstrapSetupAvailable();
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()
);
const formData = await request.formData();
assertBootstrapToken(String(formData.get('bootstrapToken') ?? request.headers.get('x-bootstrap-setup-token') ?? ''));
if (!file) return Promise.resolve();
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 });
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
});
})
);
}
const files = await Promise.all(
async function readMultipartFiles(formData: FormData): Promise<CsvPreviewFileInput[]> {
const uploadedFiles = Array.from(formData.values()).filter(isFile);
return Promise.all(
uploadedFiles.map(async (file) => ({
fileName: file.name,
bytes: new Uint8Array(await file.arrayBuffer())
}))
);
}
const step = assertBootstrapStepFiles(stepKey, files);
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(files);
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(preview),
output: toSerializableRecord(sanitizeCsvPreviewFileForPersistence(preview, step.fileName)),
errors: preview.files.flatMap((file) => file.errors),
warnings: preview.files.flatMap((file) => file.warnings)
});
return NextResponse.json(preview);
return NextResponse.json(sanitizeCsvPreviewForResponse(preview));
}
export async function POST(request: NextRequest) {
try {
await assertBootstrapSetupAvailable();
if (request.headers.get('content-type')?.includes('application/json')) {
return await previewConfiguredDirectory(request);
}
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 });
}
}

View File

@@ -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 },
);
}
}

View File

@@ -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';
}
export async function POST(request: NextRequest) {
try {
const session = await requireSystemRole('super_admin');
async function readMultipartFiles(formData: FormData): Promise<CsvPreviewFileInput[]> {
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 uploadedFiles = Array.from(formData.values()).filter(isFile);
const files = await readMultipartFiles(formData);
if (uploadedFiles.length === 0) {
if (files.length === 0) {
return NextResponse.json(
{ message: 'Upload at least one setup CSV file using multipart/form-data.' },
{ status: 400 }
);
}
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
actorId
});
if (!dryRun && report.status !== 'FAIL') {
await createCsvImportSeedManifest({
files,
report,
actorId: session.user.id
});
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');
if (request.headers.get('content-type')?.includes('application/json')) {
return await commitConfiguredDirectory(request, session.user.id);
}
return await commitUploadedFiles(request, session.user.id);
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });

View File

@@ -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;
}
export async function POST(request: NextRequest) {
try {
await requireSystemRole('super_admin');
const formData = await request.formData();
async function readMultipartFiles(formData: FormData): Promise<CsvPreviewFileInput[]> {
const uploadedFiles = Array.from(formData.values()).filter(isFile);
if (uploadedFiles.length === 0) {
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 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(sanitizeCsvPreviewForResponse(preview));
}
return NextResponse.json(preview);
export async function POST(request: NextRequest) {
try {
await requireSystemRole('super_admin');
if (request.headers.get('content-type')?.includes('application/json')) {
return await previewConfiguredDirectory(request);
}
return await previewUploadedFiles(request);
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });

View File

@@ -13,7 +13,7 @@ export default async function AdministrationSetupPage() {
return (
<PageContainer
pageTitle='Administration Setup Wizard'
pageDescription='Run readiness checks, review setup templates, preview CSV imports, commit imports, and verify the configured system.'
pageDescription='Preview setup CSV files from SETUP_IMPORT_DIR, run the import, and verify the configured system.'
access={canAccessSetup}
accessFallback={
<div className='rounded-lg border border-dashed p-8 text-center text-muted-foreground'>

View File

@@ -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(),

View File

@@ -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<SetupReadinessReport> {
return apiClient<SetupReadinessReport>(withProfile('/setup/readiness', profileId));
}
export async function getSetupTemplates(profileId?: string): Promise<SetupTemplatesResponse> {
return apiClient<SetupTemplatesResponse>(withProfile('/setup/templates', profileId));
}
export async function getSetupProfiles(profileId?: string): Promise<SetupProfilesResponse> {
return apiClient<SetupProfilesResponse>(withProfile('/setup/profiles', profileId));
}
export async function getBootstrapSetupStatus(): Promise<BootstrapSetupStatusResponse> {
return apiClient<BootstrapSetupStatusResponse>('/setup/bootstrap/status');
}
export async function getCurrentSetupRun(): Promise<SetupRunResponse> {
return apiClient<SetupRunResponse>('/setup/run/current');
}
export async function startSetupRun(): Promise<SetupRunResponse> {
return apiClient<SetupRunResponse>('/setup/run/start', {
method: 'POST',
body: JSON.stringify({ mode: 'production' })
});
}
export async function saveSetupStep(payload: SaveSetupStepPayload): Promise<SetupRunResponse> {
return apiClient<SetupRunResponse>('/setup/run/steps', {
method: 'POST',
body: JSON.stringify(payload)
});
}
export async function completeSetupRun(report: Record<string, unknown>): Promise<SetupRunResponse> {
return apiClient<SetupRunResponse>('/setup/run/complete', {
method: 'POST',
body: JSON.stringify({ report })
});
}
export async function getSetupRunReport(): Promise<SetupRunResponse> {
return apiClient<SetupRunResponse>('/setup/run/report');
}
export async function verifySetup(
payload: SetupVerificationPayload = {}
): Promise<SetupVerificationReport> {
return apiClient<SetupVerificationReport>('/setup/verify', {
method: 'POST',
body: JSON.stringify(payload)
});
}
async function readUploadResponse<T>(response: Response): Promise<T> {
if (response.ok) {
return response.json() as Promise<T>;
@@ -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<SetupReadinessReport> {
return apiClient<SetupReadinessReport>(withProfile('/setup/readiness', profileId));
}
export async function getSetupTemplates(profileId?: string): Promise<SetupTemplatesResponse> {
return apiClient<SetupTemplatesResponse>(withProfile('/setup/templates', profileId));
}
export async function getSetupProfiles(profileId?: string): Promise<SetupProfilesResponse> {
return apiClient<SetupProfilesResponse>(withProfile('/setup/profiles', profileId));
}
export async function getBootstrapSetupStatus(): Promise<BootstrapSetupStatusResponse> {
return apiClient<BootstrapSetupStatusResponse>('/setup/bootstrap/status');
}
export async function getCurrentSetupRun(): Promise<SetupRunResponse> {
return apiClient<SetupRunResponse>('/setup/run/current');
}
export async function getCurrentBootstrapSetupRun(bootstrapToken?: string): Promise<SetupRunResponse> {
return readUploadResponse<SetupRunResponse>(
await fetch('/api/setup/bootstrap/run/current', {
headers: bootstrapHeaders(bootstrapToken)
})
);
}
export async function startSetupRun(): Promise<SetupRunResponse> {
return apiClient<SetupRunResponse>('/setup/run/start', {
method: 'POST',
body: JSON.stringify({ mode: 'production' })
});
}
export async function saveSetupStep(payload: SaveSetupStepPayload): Promise<SetupRunResponse> {
return apiClient<SetupRunResponse>('/setup/run/steps', {
method: 'POST',
body: JSON.stringify(payload)
});
}
export async function getSetupRunReport(): Promise<SetupRunResponse> {
return apiClient<SetupRunResponse>('/setup/run/report');
}
export async function completeSetupRun(report: Record<string, unknown>): Promise<SetupRunResponse> {
return apiClient<SetupRunResponse>('/setup/run/complete', {
method: 'POST',
body: JSON.stringify({ report })
});
}
export async function verifySetup(payload: SetupVerificationPayload): Promise<SetupVerificationReport> {
return apiClient<SetupVerificationReport>('/setup/verify', {
method: 'POST',
body: JSON.stringify(payload)
});
}
export async function previewSetupCsvFiles(files: File[]): Promise<CsvPreviewResult> {
return readUploadResponse<CsvPreviewResult>(
await fetch('/api/setup/import/preview', {
@@ -107,6 +118,16 @@ export async function previewSetupCsvFiles(files: File[]): Promise<CsvPreviewRes
);
}
export async function previewConfiguredSetupCsvFiles(): Promise<CsvPreviewResult> {
return readUploadResponse<CsvPreviewResult>(
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<CsvPreviewResult> {
return readUploadResponse<CsvPreviewResult>(
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<ImportReport> {
return readUploadResponse<ImportReport>(
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<string, unknown>): Promise<SetupRunResponse> {
export async function commitConfiguredBootstrapCsv(input: {
previewHash: string;
dryRun?: boolean;
bootstrapToken?: string;
}): Promise<ImportReport> {
return readUploadResponse<ImportReport>(
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<SetupRunResponse> {
return apiClient<SetupRunResponse>('/setup/bootstrap/complete', {
method: 'POST',
body: JSON.stringify({ report })
body: JSON.stringify({
bootstrapToken: input.bootstrapToken,
report: input.report ?? {}
})
});
}

View File

@@ -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<string, unknown>;
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";

View File

@@ -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)
});
}

View File

@@ -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<string, unknown>) => completeBootstrapSetup(report),
mutationFn: (input: CompleteBootstrapSetupPayload) => completeBootstrapSetup(input),
onSuccess: invalidate
});
}

View File

@@ -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 <Badge variant={statusBadgeVariant(status)}>{status}</Badge>;
}
@@ -114,57 +111,13 @@ function CsvErrors({ errors }: { errors: CsvPreviewError[] }) {
);
}
function PreviewFilePanel({ file }: { file: CsvPreviewFileResult }) {
function LoadingAlert({ phase }: { phase: string }) {
return (
<div className='rounded-md border p-3'>
<div className='mb-3 flex flex-wrap items-center justify-between gap-2'>
<div>
<div className='font-medium'>{file.fileName}</div>
<div className='text-sm text-muted-foreground'>Template: {file.template}</div>
</div>
<StepStatusBadge status={file.status} />
</div>
<div className='grid gap-2 sm:grid-cols-5'>
<Metric label='Rows' value={file.summary.rows} />
<Metric label='Create' value={file.summary.create} />
<Metric label='Update' value={file.summary.update} />
<Metric label='Warning' value={file.summary.warning} />
<Metric label='Error' value={file.summary.error} />
</div>
<div className='mt-3'>
<CsvErrors errors={[...file.errors, ...file.warnings]} />
</div>
</div>
);
}
function ImportReportPanel({ report }: { report: ImportReport | null }) {
if (!report) {
return (
<div className='rounded-md border border-dashed p-6 text-center text-sm text-muted-foreground'>
Import has not run for this step yet.
</div>
);
}
return (
<div className='space-y-3'>
<div className='flex flex-wrap items-center justify-between gap-2'>
<div>
<div className='font-medium'>Import result</div>
<div className='break-all text-sm text-muted-foreground'>Committed hash: {report.committedHash}</div>
</div>
<StepStatusBadge status={report.status} />
</div>
<div className='grid gap-2 sm:grid-cols-5'>
<Metric label='Imported' value={report.summary.imported} />
<Metric label='Updated' value={report.summary.updated} />
<Metric label='Skipped' value={report.summary.skipped} />
<Metric label='Warnings' value={report.summary.warnings} />
<Metric label='Errors' value={report.summary.errors} />
</div>
<CsvErrors errors={[...report.errors, ...report.warnings]} />
</div>
<Alert>
<Icons.spinner className='h-4 w-4 animate-spin' />
<AlertTitle>Working</AlertTitle>
<AlertDescription>{phase}</AlertDescription>
</Alert>
);
}
@@ -172,169 +125,419 @@ function Stepper({
steps,
currentStepKey,
stepByKey,
fileByName,
previewByFileName,
onSelect
}: {
steps: BootstrapRequiredStepDto[];
currentStepKey: string;
stepByKey: Map<string, SetupRunStepDto>;
fileByName: Map<string, SetupImportSourceFileDto>;
previewByFileName: Map<string, CsvPreviewFileResult>;
onSelect: (stepKey: string) => void;
}) {
return (
<Card className='self-start'>
<CardHeader>
<CardTitle>System Required</CardTitle>
<CardDescription>Import required setup CSV files in dependency order.</CardDescription>
<CardDescription>Click a row to inspect the CSV file detail.</CardDescription>
</CardHeader>
<CardContent className='space-y-2'>
{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 (
<button
key={step.stepKey}
type='button'
onClick={() => onSelect(step.stepKey)}
className={cn(
'flex min-h-14 w-full items-center gap-3 rounded-md border px-3 py-2 text-left text-sm transition-colors',
currentStepKey === step.stepKey && 'border-primary bg-primary/10',
locked && 'cursor-not-allowed opacity-60'
'flex min-h-16 w-full items-center gap-3 rounded-md border px-3 py-2 text-left text-sm transition-colors hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
selected && 'border-primary bg-primary/10'
)}
disabled={locked}
>
<span className='flex size-8 shrink-0 items-center justify-center rounded-full border bg-background text-xs font-semibold'>
{index + 1}
</span>
<span className='min-w-0 flex-1'>
<span className='block truncate font-medium'>{step.title}</span>
<span className='block truncate text-xs text-muted-foreground'>{step.fileName}</span>
<span className='flex items-center gap-2'>
{sourceFile ? (
<Icons.circleCheck className='size-4 shrink-0 text-primary' />
) : (
<Icons.alertCircle className='size-4 shrink-0 text-destructive' />
)}
<span className='truncate font-medium'>{step.title}</span>
</span>
<StepStatusBadge status={locked ? 'locked' : (persisted?.status ?? 'not_started')} />
<span className='mt-1 block truncate text-xs text-muted-foreground'>{step.fileName}</span>
</span>
<div className='flex shrink-0 flex-col items-end gap-1'>
<StepStatusBadge status={sourceFile ? 'found' : 'missing'} />
<StepStatusBadge status={previewFile?.status ?? persisted?.status ?? 'not_started'} />
</div>
</button>
);
})}
<div className='pt-4'>
<div className='mb-2 text-xs font-medium uppercase text-muted-foreground'>Optional Business Data</div>
<div className='space-y-1'>
{BUSINESS_IMPORT_FILES.map((fileName) => (
<div
key={fileName}
className='flex min-h-9 items-center justify-between rounded-md bg-muted/30 px-3 text-sm text-muted-foreground'
>
<span>{fileName.replace('.csv', '')}</span>
<Badge variant='outline'>later</Badge>
</div>
))}
</div>
</div>
</CardContent>
</Card>
);
}
export function SetupWizard() {
function SourcePanel({ source }: { source?: SetupImportSourceDto }) {
if (!source) {
return (
<Card>
<CardHeader>
<CardTitle>Configured Import Folder</CardTitle>
<CardDescription>Loading setup import source.</CardDescription>
</CardHeader>
</Card>
);
}
return (
<Card>
<CardHeader>
<div className='flex flex-wrap items-start justify-between gap-3'>
<div>
<CardTitle>Configured Import Folder</CardTitle>
<CardDescription>
Configure CSV source with {source.envKey} in .env, then restart the server.
</CardDescription>
</div>
<Badge variant={source.errors.length > 0 ? 'destructive' : source.warnings.length > 0 ? 'secondary' : 'default'}>
{source.errors.length > 0 ? 'Not ready' : source.warnings.length > 0 ? 'Warning' : 'Ready'}
</Badge>
</div>
</CardHeader>
<CardContent className='space-y-4'>
<div className='grid gap-3 md:grid-cols-2'>
<div className='rounded-md border p-3'>
<div className='text-xs font-medium uppercase text-muted-foreground'>Env key</div>
<div className='mt-1 font-medium'>{source.envKey}</div>
</div>
<div className='rounded-md border p-3'>
<div className='text-xs font-medium uppercase text-muted-foreground'>Configured value</div>
<div className='mt-1 break-all font-medium'>{source.configuredValue ?? 'Not configured'}</div>
</div>
<div className='rounded-md border p-3 md:col-span-2'>
<div className='text-xs font-medium uppercase text-muted-foreground'>Resolved path</div>
<div className='mt-1 break-all text-sm'>{source.resolvedPath ?? 'Unavailable'}</div>
</div>
</div>
{source.errors.length > 0 ? (
<Alert variant='destructive'>
<Icons.alertCircle className='h-4 w-4' />
<AlertTitle>Import folder requires configuration</AlertTitle>
<AlertDescription>
<div className='space-y-2'>
{source.errors.map((error) => (
<div key={error}>{error}</div>
))}
<code className='block rounded-md bg-background px-3 py-2 text-xs'>
{source.envKey}={source.exampleValue}
</code>
</div>
</AlertDescription>
</Alert>
) : null}
</CardContent>
</Card>
);
}
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 (
<Card>
<CardHeader>
<div className='flex flex-wrap items-start justify-between gap-3'>
<div>
<CardTitle>{step.title}</CardTitle>
<CardDescription>{step.fileName}</CardDescription>
</div>
<StepStatusBadge status={sourceFile ? 'found' : 'missing'} />
</div>
</CardHeader>
<CardContent className='space-y-4'>
{!sourceFile ? (
<Alert variant='destructive'>
<Icons.alertCircle className='h-4 w-4' />
<AlertTitle>File missing</AlertTitle>
<AlertDescription>Add {step.fileName} to the configured setup import folder.</AlertDescription>
</Alert>
) : (
<>
<div className='grid gap-2 sm:grid-cols-3'>
<Metric label='Bytes' value={sourceFile.bytes.toLocaleString()} />
<Metric label='Kind' value={sourceFile.kind} />
<Metric label='Preview' value={previewFile?.status ?? 'not_run'} />
</div>
<div className='rounded-md border p-3 text-sm'>
<div className='text-xs font-medium uppercase text-muted-foreground'>Modified</div>
<div className='mt-1'>{sourceFile.modifiedAt}</div>
<div className='mt-3 text-xs font-medium uppercase text-muted-foreground'>Checksum</div>
<div className='mt-1 break-all'>{sourceFile.checksum}</div>
</div>
<div className='rounded-md border p-3'>
<div className='text-xs font-medium uppercase text-muted-foreground'>Columns</div>
<div className='mt-2 flex flex-wrap gap-2'>
{sourceFile.headers.map((header) => (
<Badge key={header} variant='outline'>
{header}
</Badge>
))}
</div>
</div>
</>
)}
<div className='rounded-md border p-3'>
<div className='text-xs font-medium uppercase text-muted-foreground'>Required headers</div>
<div className='mt-2 flex flex-wrap gap-2'>
{(template?.requiredColumns ?? []).map((column) => (
<Badge key={column} variant='secondary'>
{column}
</Badge>
))}
</div>
</div>
{previewFile ? (
<div className='space-y-3'>
<div className='grid gap-2 sm:grid-cols-7'>
<Metric label='Rows' value={previewFile.summary.rows} />
<Metric label='Valid' value={previewFile.summary.valid} />
<Metric label='Create' value={previewFile.summary.create} />
<Metric label='Update' value={previewFile.summary.update} />
<Metric label='Skip' value={previewFile.summary.skip} />
<Metric label='Warning' value={previewFile.summary.warning} />
<Metric label='Error' value={previewFile.summary.error} />
</div>
{sampleRows.length > 0 ? (
<div className='overflow-x-auto rounded-md border'>
<table className='w-full min-w-[720px] text-sm'>
<thead className='bg-muted/50 text-left'>
<tr>
<th className='px-3 py-2 font-medium'>Row</th>
<th className='px-3 py-2 font-medium'>Action</th>
{sampleColumns.map((column) => (
<th key={column} className='px-3 py-2 font-medium'>
{column}
</th>
))}
</tr>
</thead>
<tbody>
{sampleRows.map((row) => (
<tr key={row.row} className='border-t'>
<td className='px-3 py-2'>{row.row}</td>
<td className='px-3 py-2'>{row.action}</td>
{sampleColumns.map((column) => (
<td key={`${row.row}-${column}`} className='max-w-[220px] truncate px-3 py-2'>
{row.values[column] || '-'}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
) : null}
<CsvErrors errors={[...previewFile.errors, ...previewFile.warnings]} />
</div>
) : (
<div className='rounded-md border border-dashed p-6 text-center text-sm text-muted-foreground'>
Click Execute Import Seed Data to preview and import the configured CSV files.
</div>
)}
</CardContent>
</Card>
);
}
function ImportReportPanel({ report }: { report: ImportReport | null }) {
if (!report) return null;
return (
<Alert variant={report.status === 'FAIL' ? 'destructive' : 'default'}>
{report.status === 'FAIL' ? <Icons.alertCircle className='h-4 w-4' /> : <Icons.circleCheck className='h-4 w-4' />}
<AlertTitle>{report.status === 'FAIL' ? 'Import failed and was rolled back' : 'Import completed successfully'}</AlertTitle>
<AlertDescription>
Imported {report.summary.imported}, updated {report.summary.updated}, skipped {report.summary.skipped}, warnings{' '}
{report.summary.warnings}, errors {report.summary.errors}.
</AlertDescription>
</Alert>
);
}
interface SetupWizardProps {
mode?: 'admin' | 'public';
tokenRequired?: boolean;
}
export function SetupWizard({ mode = 'admin', tokenRequired = false }: SetupWizardProps) {
const [currentStepKey, setCurrentStepKey] = useState('');
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [preview, setPreview] = useState<CsvPreviewResult | null>(null);
const [importReport, setImportReport] = useState<ImportReport | null>(null);
const [warningAcknowledged, setWarningAcknowledged] = useState(false);
const [bootstrapToken, setBootstrapToken] = useState('');
const [workingMessage, setWorkingMessage] = useState<string | null>(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)
});
}
if (bootstrapStatusQuery.isLoading || templatesQuery.isLoading || setupRunQuery.isLoading) {
return (
<div className='rounded-md border border-dashed p-6 text-center text-sm text-muted-foreground'>
Loading first-run setup wizard...
Loading setup import preview...
</div>
);
}
if (bootstrapStatusQuery.isError) {
return <ErrorAlert message={getErrorMessage(bootstrapStatusQuery.error)} />;
}
if (!currentStep) {
return (
<Alert>
@@ -346,126 +549,34 @@ export function SetupWizard() {
}
return (
<div className='grid gap-6 lg:grid-cols-[360px_1fr]'>
<Stepper steps={steps} currentStepKey={currentStep.stepKey} stepByKey={stepByKey} onSelect={selectStep} />
<div className='space-y-6'>
<Alert>
<Icons.info className='h-4 w-4' />
<AlertTitle>Import order decision</AlertTitle>
<AlertDescription>
Users are imported before organizations because organizations.csv resolves created_by_email
against existing users.
</AlertDescription>
</Alert>
{bootstrapStatusQuery.data?.initialized ? (
<Alert>
<Icons.circleCheck className='h-4 w-4' />
<AlertTitle>Setup already initialized</AlertTitle>
<AlertDescription>Public bootstrap setup is blocked after completion.</AlertDescription>
</Alert>
) : null}
<Card>
<CardHeader>
<div className='flex flex-wrap items-start justify-between gap-3'>
<div className='flex flex-wrap items-start justify-between gap-3 rounded-lg border bg-background p-4'>
<div>
<CardTitle>{currentStep.title}</CardTitle>
<CardDescription>{currentStep.description}</CardDescription>
</div>
<StepStatusBadge status={currentPersistedStep?.status ?? 'not_started'} />
</div>
</CardHeader>
<CardContent className='space-y-5'>
{!dependenciesComplete ? (
<Alert variant='destructive'>
<Icons.lock className='h-4 w-4' />
<AlertTitle>Step locked</AlertTitle>
<AlertDescription>
Complete dependencies first: {currentStep.dependsOn.join(', ')}.
</AlertDescription>
</Alert>
) : null}
<div className='grid gap-3 md:grid-cols-2'>
<div className='rounded-md border p-3'>
<div className='text-xs font-medium uppercase text-muted-foreground'>Required filename</div>
<div className='mt-1 font-medium'>{currentStep.fileName}</div>
</div>
<div className='rounded-md border p-3'>
<div className='text-xs font-medium uppercase text-muted-foreground'>Purpose</div>
<div className='mt-1 text-sm'>{currentTemplate?.description ?? currentStep.description}</div>
<div className='text-lg font-semibold'>Setup Import Form</div>
<div className='text-sm text-muted-foreground'>
Execute import seed data from the configured folder. Failed imports roll back in the commit transaction.
</div>
</div>
<TemplateColumns template={currentTemplate ?? undefined} />
<div className='space-y-2'>
<div className='text-sm font-medium'>Upload</div>
<Input
type='file'
accept='.csv,text/csv'
disabled={!dependenciesComplete}
onChange={(event) => handleFile(event.target.files)}
/>
{selectedFile ? (
<div className='flex flex-wrap items-center justify-between gap-2 rounded-md bg-muted/30 px-3 py-2 text-sm'>
<span>{selectedFile.name}</span>
<span className='text-muted-foreground'>{selectedFile.size} bytes</span>
</div>
) : null}
{selectedFile && !selectedFileMatches ? (
<Alert variant='destructive'>
<Icons.alertCircle className='h-4 w-4' />
<AlertTitle>Wrong file selected</AlertTitle>
<AlertDescription>Upload {currentStep.fileName} for this step.</AlertDescription>
</Alert>
) : null}
</div>
<div className='flex flex-wrap gap-2'>
<Button
type='button'
variant='outline'
onClick={runPreview}
disabled={!dependenciesComplete || !selectedFile || !selectedFileMatches || previewMutation.isPending}
onClick={runPreviewOnly}
disabled={!bootstrapTokenReady || !sourceReady || isWorking}
>
{previewMutation.isPending ? (
<Icons.spinner className='mr-2 h-4 w-4 animate-spin' />
) : (
<Icons.upload className='mr-2 h-4 w-4' />
)}
Preview
{previewMutation.isPending ? <Icons.spinner className='mr-2 h-4 w-4 animate-spin' /> : <Icons.page className='mr-2 h-4 w-4' />}
Refresh Preview
</Button>
<Button type='button' onClick={runImport} disabled={importBlocked || commitMutation.isPending}>
{commitMutation.isPending ? (
<Icons.spinner className='mr-2 h-4 w-4 animate-spin' />
) : (
<Icons.check className='mr-2 h-4 w-4' />
)}
Run Import
<Button type='button' onClick={executeImportSeedData} disabled={submitDisabled}>
{isWorking ? <Icons.spinner className='mr-2 h-4 w-4 animate-spin' /> : <Icons.check className='mr-2 h-4 w-4' />}
Execute Import Seed Data
</Button>
</div>
{previewMutation.isError ? <ErrorAlert message={getErrorMessage(previewMutation.error)} /> : null}
{commitMutation.isError ? <ErrorAlert message={getErrorMessage(commitMutation.error)} /> : null}
<div className='space-y-3'>
<div className='flex flex-wrap items-center justify-between gap-2'>
<div className='font-medium'>Preview result</div>
{previewStatus ? <StepStatusBadge status={previewStatus} /> : null}
</div>
{preview ? (
<>
<div className='grid gap-2 sm:grid-cols-6'>
<Metric label='Files' value={preview.summary.files} />
<Metric label='Rows' value={preview.summary.rows} />
<Metric label='Create' value={preview.summary.create} />
<Metric label='Update' value={preview.summary.update} />
<Metric label='Warning' value={preview.summary.warning} />
<Metric label='Error' value={preview.summary.error} />
</div>
{workingMessage ? <LoadingAlert phase={workingMessage} /> : null}
{importReport ? <ImportReportPanel report={importReport} /> : null}
{previewStatus === 'WARNING' ? (
<div className='flex items-center gap-2 rounded-md border p-3 text-sm'>
<Checkbox
@@ -473,30 +584,51 @@ export function SetupWizard() {
checked={warningAcknowledged}
onCheckedChange={(checked) => setWarningAcknowledged(checked === true)}
/>
<label htmlFor='setup-preview-warning-acknowledgement'>
Acknowledge warnings and allow import.
</label>
<label htmlFor='setup-preview-warning-acknowledgement'>Acknowledge warnings and allow import.</label>
</div>
) : null}
{preview.files.map((file) => (
<PreviewFilePanel key={file.fileName} file={file} />
))}
</>
) : (
<div className='rounded-md border border-dashed p-6 text-center text-sm text-muted-foreground'>
Run Preview to validate this CSV before import.
</div>
)}
</div>
<ImportReportPanel report={importReport} />
{mode === 'public' && tokenRequired ? (
<Card>
<CardHeader>
<CardTitle>Bootstrap Token</CardTitle>
<CardDescription>Enter the setup token before running protected bootstrap steps.</CardDescription>
</CardHeader>
<CardContent>
<Input
type='password'
value={bootstrapToken}
onChange={(event) => setBootstrapToken(event.target.value)}
placeholder='Bootstrap setup token'
aria-label='Bootstrap setup token'
/>
</CardContent>
</Card>
) : null}
<div className='grid gap-6 lg:grid-cols-[360px_1fr]'>
<Stepper
steps={steps}
currentStepKey={currentStep.stepKey}
stepByKey={stepByKey}
fileByName={fileByName}
previewByFileName={previewByFileName}
onSelect={setCurrentStepKey}
/>
<div className='space-y-6'>
<SourcePanel source={importSource} />
<SelectedFileDetail
step={currentStep}
sourceFile={selectedSourceFile}
template={selectedTemplate}
previewFile={selectedPreviewFile}
/>
<Card>
<CardHeader>
<CardTitle>Final Verification</CardTitle>
<CardDescription>Setup can complete only after all system-required steps pass.</CardDescription>
<CardDescription>Setup can complete after system-required import steps pass.</CardDescription>
</CardHeader>
<CardContent className='space-y-4'>
<div className='grid gap-2 sm:grid-cols-3'>
@@ -507,11 +639,7 @@ export function SetupWizard() {
<Metric label='Status' value={allRequiredComplete ? 'Ready' : 'Incomplete'} />
<Metric label='Result' value={setupRunQuery.data?.run?.status ?? 'not_started'} />
</div>
<Button
type='button'
onClick={completeSetup}
disabled={!allRequiredComplete || completeMutation.isPending}
>
<Button type='button' onClick={completeSetup} disabled={!allRequiredComplete || completeMutation.isPending}>
{completeMutation.isPending ? (
<Icons.spinner className='mr-2 h-4 w-4 animate-spin' />
) : (
@@ -524,36 +652,6 @@ export function SetupWizard() {
</Card>
</div>
</div>
);
}
function TemplateColumns({ template }: { template?: SetupTemplateMetadataItem }) {
if (!template) {
return (
<div className='rounded-md border border-dashed p-4 text-sm text-muted-foreground'>
Template metadata is not available for this file.
</div>
);
}
return (
<div className='space-y-3 rounded-md border p-3'>
<div>
<div className='text-xs font-medium uppercase text-muted-foreground'>Required headers</div>
<div className='mt-2 flex flex-wrap gap-2'>
{template.requiredColumns.map((column) => (
<Badge key={column} variant='secondary'>
{column}
</Badge>
))}
</div>
</div>
<div>
<div className='text-xs font-medium uppercase text-muted-foreground'>Sample template</div>
<code className='mt-2 block overflow-x-auto rounded-md bg-muted px-3 py-2 text-xs'>
setup/templates/{template.file}
</code>
</div>
</div>
);
}

View File

@@ -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<string | null> {
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 };
}

View File

@@ -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<string, string>) {
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<T>(query: Promise<Array<T & { value: number }>>) {
@@ -161,7 +223,7 @@ async function hasRows<T>(query: Promise<Array<T & { value: number }>>) {
}
export async function getBootstrapSetupStatus(): Promise<BootstrapSetupStatus> {
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<BootstrapSetupStatus> {
.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<BootstrapSetupStatus> {
checks,
requiredFiles: [...BOOTSTRAP_REQUIRED_TEMPLATE_FILES],
optionalFiles: [...BOOTSTRAP_OPTIONAL_TEMPLATE_FILES],
requiredSteps: [...BOOTSTRAP_REQUIRED_STEPS]
requiredSteps: [...BOOTSTRAP_REQUIRED_STEPS],
importSource
};
}

View File

@@ -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<SetupImportSourceReadResult> {
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<string>();
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 };
}

View File

@@ -39,6 +39,7 @@ export interface SaveSetupStepInput {
output?: Record<string, unknown> | 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) =>

View File

@@ -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(','))

View File

@@ -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/);
});

View File

@@ -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\)/);
});

View File

@@ -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<TemplateMetadata>('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/);
});

View File

@@ -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', () => {

View File

@@ -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/);
});