task-d.7.11

This commit is contained in:
phaichayon
2026-07-03 14:46:42 +07:00
parent 9c8d6a0d6d
commit 15c56efc8b
20 changed files with 1790 additions and 5983 deletions

View File

@@ -446,11 +446,12 @@ CREATE TABLE "crm_leads" (
"description" text, "description" text,
"project_name" text, "project_name" text,
"project_location" text, "project_location" text,
"lead_channel" text,
"product_type" text, "product_type" text,
"priority" text, "priority" text,
"estimated_value" double precision, "estimated_value" double precision,
"awareness_id" text, "awareness_id" text,
"status" text DEFAULT 'new_job' NOT NULL, "status" text NOT NULL,
"followup_status" text, "followup_status" text,
"lost_reason" text, "lost_reason" text,
"outcome" text DEFAULT 'open' NOT NULL, "outcome" text DEFAULT 'open' NOT NULL,
@@ -486,10 +487,14 @@ CREATE TABLE "crm_opportunities" (
"estimated_value" double precision, "estimated_value" double precision,
"chance_percent" integer, "chance_percent" integer,
"expected_close_date" timestamp with time zone, "expected_close_date" timestamp with time zone,
"project_close_date" timestamp with time zone,
"delivery_date" timestamp with time zone,
"competitor" text, "competitor" text,
"source" text, "source" text,
"notes" text, "notes" text,
"is_hot_project" boolean DEFAULT false NOT NULL, "is_hot_project" boolean DEFAULT false NOT NULL,
"hot_project_auto_suggested" boolean DEFAULT false NOT NULL,
"hot_project_manually_overridden" boolean DEFAULT false NOT NULL,
"is_active" boolean DEFAULT true NOT NULL, "is_active" boolean DEFAULT true NOT NULL,
"pipeline_stage" text DEFAULT 'lead' NOT NULL, "pipeline_stage" text DEFAULT 'lead' NOT NULL,
"closed_at" timestamp with time zone, "closed_at" timestamp with time zone,
@@ -687,7 +692,11 @@ CREATE TABLE "crm_quotations" (
"tax_amount" double precision DEFAULT 0 NOT NULL, "tax_amount" double precision DEFAULT 0 NOT NULL,
"total_amount" double precision DEFAULT 0 NOT NULL, "total_amount" double precision DEFAULT 0 NOT NULL,
"chance_percent" integer, "chance_percent" integer,
"project_close_date" timestamp with time zone,
"delivery_date" timestamp with time zone,
"is_hot_project" boolean DEFAULT false NOT NULL, "is_hot_project" boolean DEFAULT false NOT NULL,
"hot_project_auto_suggested" boolean DEFAULT false NOT NULL,
"hot_project_manually_overridden" boolean DEFAULT false NOT NULL,
"competitor" text, "competitor" text,
"salesman_id" text, "salesman_id" text,
"is_sent" boolean DEFAULT false NOT NULL, "is_sent" boolean DEFAULT false NOT NULL,
@@ -829,6 +838,70 @@ CREATE TABLE "products" (
"updated_at" timestamp with time zone DEFAULT now() NOT NULL "updated_at" timestamp with time zone DEFAULT now() NOT NULL
); );
--> statement-breakpoint --> statement-breakpoint
CREATE TABLE "seed_manifest_items" (
"id" text PRIMARY KEY NOT NULL,
"seed_manifest_id" text NOT NULL,
"item_key" text NOT NULL,
"source_file" text NOT NULL,
"checksum" text NOT NULL,
"status" text DEFAULT 'pending' NOT NULL,
"imported_count" integer DEFAULT 0 NOT NULL,
"updated_count" integer DEFAULT 0 NOT NULL,
"skipped_count" integer DEFAULT 0 NOT NULL,
"error_count" integer DEFAULT 0 NOT NULL,
"warning_count" integer DEFAULT 0 NOT NULL,
"report_json" jsonb,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "seed_manifests" (
"id" text PRIMARY KEY NOT NULL,
"setup_run_id" text,
"organization_id" text,
"name" text NOT NULL,
"version" text NOT NULL,
"type" text NOT NULL,
"checksum" text NOT NULL,
"status" text DEFAULT 'pending' NOT NULL,
"source" text NOT NULL,
"applied_by" text,
"applied_at" timestamp with time zone,
"report_json" jsonb,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "setup_run_steps" (
"id" text PRIMARY KEY NOT NULL,
"setup_run_id" text NOT NULL,
"step_key" text NOT NULL,
"status" text DEFAULT 'not_started' NOT NULL,
"input_hash" text,
"output_json" jsonb,
"errors_json" jsonb,
"warnings_json" jsonb,
"started_at" timestamp with time zone,
"completed_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "setup_runs" (
"id" text PRIMARY KEY NOT NULL,
"status" text DEFAULT 'not_started' NOT NULL,
"mode" text DEFAULT 'production' NOT NULL,
"target_organization_id" text,
"started_by" text NOT NULL,
"completed_by" text,
"started_at" timestamp with time zone DEFAULT now() NOT NULL,
"completed_at" timestamp with time zone,
"last_error" text,
"metadata" jsonb,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "tr_audit_logs" ( CREATE TABLE "tr_audit_logs" (
"id" text PRIMARY KEY NOT NULL, "id" text PRIMARY KEY NOT NULL,
"organization_id" text NOT NULL, "organization_id" text NOT NULL,
@@ -879,4 +952,6 @@ CREATE UNIQUE INDEX "crm_user_role_assignments_org_user_role_idx" ON "crm_user_r
CREATE UNIQUE INDEX "document_sequences_org_branch_product_doc_period_idx" ON "document_sequences" USING btree ("organization_id","branch_id","product_type","document_type","period");--> statement-breakpoint CREATE UNIQUE INDEX "document_sequences_org_branch_product_doc_period_idx" ON "document_sequences" USING btree ("organization_id","branch_id","product_type","document_type","period");--> statement-breakpoint
CREATE UNIQUE INDEX "ms_options_org_category_code_idx" ON "ms_options" USING btree ("organization_id","category","code");--> statement-breakpoint CREATE UNIQUE INDEX "ms_options_org_category_code_idx" ON "ms_options" USING btree ("organization_id","category","code");--> statement-breakpoint
CREATE UNIQUE INDEX "organizations_slug_idx" ON "organizations" USING btree ("slug");--> statement-breakpoint CREATE UNIQUE INDEX "organizations_slug_idx" ON "organizations" USING btree ("slug");--> statement-breakpoint
CREATE UNIQUE INDEX "seed_manifest_items_manifest_item_idx" ON "seed_manifest_items" USING btree ("seed_manifest_id","item_key");--> statement-breakpoint
CREATE UNIQUE INDEX "setup_run_steps_run_step_idx" ON "setup_run_steps" USING btree ("setup_run_id","step_key");--> statement-breakpoint
CREATE UNIQUE INDEX "users_email_idx" ON "users" USING btree ("email"); CREATE UNIQUE INDEX "users_email_idx" ON "users" USING btree ("email");

View File

@@ -1,5 +0,0 @@
ALTER TABLE "crm_leads"
ADD COLUMN IF NOT EXISTS "lead_channel" text;
ALTER TABLE "crm_leads"
ALTER COLUMN "status" DROP DEFAULT;

View File

@@ -1,11 +0,0 @@
ALTER TABLE "crm_opportunities"
ADD COLUMN "project_close_date" timestamp with time zone,
ADD COLUMN "delivery_date" timestamp with time zone,
ADD COLUMN "hot_project_auto_suggested" boolean DEFAULT false NOT NULL,
ADD COLUMN "hot_project_manually_overridden" boolean DEFAULT false NOT NULL;
ALTER TABLE "crm_quotations"
ADD COLUMN "project_close_date" timestamp with time zone,
ADD COLUMN "delivery_date" timestamp with time zone,
ADD COLUMN "hot_project_auto_suggested" boolean DEFAULT false NOT NULL,
ADD COLUMN "hot_project_manually_overridden" boolean DEFAULT false NOT NULL;

View File

@@ -1,10 +0,0 @@
ALTER TABLE "crm_leads" ALTER COLUMN "status" DROP DEFAULT;--> statement-breakpoint
ALTER TABLE "crm_leads" ADD COLUMN "lead_channel" text;--> statement-breakpoint
ALTER TABLE "crm_opportunities" ADD COLUMN "project_close_date" timestamp with time zone;--> statement-breakpoint
ALTER TABLE "crm_opportunities" ADD COLUMN "delivery_date" timestamp with time zone;--> statement-breakpoint
ALTER TABLE "crm_opportunities" ADD COLUMN "hot_project_auto_suggested" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "crm_opportunities" ADD COLUMN "hot_project_manually_overridden" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "crm_quotations" ADD COLUMN "project_close_date" timestamp with time zone;--> statement-breakpoint
ALTER TABLE "crm_quotations" ADD COLUMN "delivery_date" timestamp with time zone;--> statement-breakpoint
ALTER TABLE "crm_quotations" ADD COLUMN "hot_project_auto_suggested" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "crm_quotations" ADD COLUMN "hot_project_manually_overridden" boolean DEFAULT false NOT NULL;

View File

@@ -1,67 +0,0 @@
CREATE TABLE IF NOT EXISTS "setup_runs" (
"id" text PRIMARY KEY NOT NULL,
"status" text DEFAULT 'not_started' NOT NULL,
"mode" text DEFAULT 'production' NOT NULL,
"target_organization_id" text,
"started_by" text NOT NULL,
"completed_by" text,
"started_at" timestamp with time zone DEFAULT now() NOT NULL,
"completed_at" timestamp with time zone,
"last_error" text,
"metadata" jsonb,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "setup_run_steps" (
"id" text PRIMARY KEY NOT NULL,
"setup_run_id" text NOT NULL,
"step_key" text NOT NULL,
"status" text DEFAULT 'not_started' NOT NULL,
"input_hash" text,
"output_json" jsonb,
"errors_json" jsonb,
"warnings_json" jsonb,
"started_at" timestamp with time zone,
"completed_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "setup_run_steps_run_step_idx" ON "setup_run_steps" ("setup_run_id","step_key");
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "seed_manifests" (
"id" text PRIMARY KEY NOT NULL,
"setup_run_id" text,
"organization_id" text,
"name" text NOT NULL,
"version" text NOT NULL,
"type" text NOT NULL,
"checksum" text NOT NULL,
"status" text DEFAULT 'pending' NOT NULL,
"source" text NOT NULL,
"applied_by" text,
"applied_at" timestamp with time zone,
"report_json" jsonb,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "seed_manifest_items" (
"id" text PRIMARY KEY NOT NULL,
"seed_manifest_id" text NOT NULL,
"item_key" text NOT NULL,
"source_file" text NOT NULL,
"checksum" text NOT NULL,
"status" text DEFAULT 'pending' NOT NULL,
"imported_count" integer DEFAULT 0 NOT NULL,
"updated_count" integer DEFAULT 0 NOT NULL,
"skipped_count" integer DEFAULT 0 NOT NULL,
"error_count" integer DEFAULT 0 NOT NULL,
"warning_count" integer DEFAULT 0 NOT NULL,
"report_json" jsonb,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "seed_manifest_items_manifest_item_idx" ON "seed_manifest_items" ("seed_manifest_id","item_key");

View File

@@ -1,5 +1,5 @@
{ {
"id": "6eb8d614-2b85-48c3-919e-af8fa13f0fb6", "id": "cdc9d738-9191-4a58-96bc-66ac8bdb7d7f",
"prevId": "00000000-0000-0000-0000-000000000000", "prevId": "00000000-0000-0000-0000-000000000000",
"version": "7", "version": "7",
"dialect": "postgresql", "dialect": "postgresql",
@@ -3045,6 +3045,12 @@
"primaryKey": false, "primaryKey": false,
"notNull": false "notNull": false
}, },
"lead_channel": {
"name": "lead_channel",
"type": "text",
"primaryKey": false,
"notNull": false
},
"product_type": { "product_type": {
"name": "product_type", "name": "product_type",
"type": "text", "type": "text",
@@ -3073,8 +3079,7 @@
"name": "status", "name": "status",
"type": "text", "type": "text",
"primaryKey": false, "primaryKey": false,
"notNull": true, "notNull": true
"default": "'new_job'"
}, },
"followup_status": { "followup_status": {
"name": "followup_status", "name": "followup_status",
@@ -3307,6 +3312,18 @@
"primaryKey": false, "primaryKey": false,
"notNull": false "notNull": false
}, },
"project_close_date": {
"name": "project_close_date",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"delivery_date": {
"name": "delivery_date",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"competitor": { "competitor": {
"name": "competitor", "name": "competitor",
"type": "text", "type": "text",
@@ -3332,6 +3349,20 @@
"notNull": true, "notNull": true,
"default": false "default": false
}, },
"hot_project_auto_suggested": {
"name": "hot_project_auto_suggested",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"hot_project_manually_overridden": {
"name": "hot_project_manually_overridden",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"is_active": { "is_active": {
"name": "is_active", "name": "is_active",
"type": "boolean", "type": "boolean",
@@ -4526,6 +4557,18 @@
"primaryKey": false, "primaryKey": false,
"notNull": false "notNull": false
}, },
"project_close_date": {
"name": "project_close_date",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"delivery_date": {
"name": "delivery_date",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"is_hot_project": { "is_hot_project": {
"name": "is_hot_project", "name": "is_hot_project",
"type": "boolean", "type": "boolean",
@@ -4533,6 +4576,20 @@
"notNull": true, "notNull": true,
"default": false "default": false
}, },
"hot_project_auto_suggested": {
"name": "hot_project_auto_suggested",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"hot_project_manually_overridden": {
"name": "hot_project_manually_overridden",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"competitor": { "competitor": {
"name": "competitor", "name": "competitor",
"type": "text", "type": "text",
@@ -5576,6 +5633,433 @@
"checkConstraints": {}, "checkConstraints": {},
"isRLSEnabled": false "isRLSEnabled": false
}, },
"public.seed_manifest_items": {
"name": "seed_manifest_items",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"seed_manifest_id": {
"name": "seed_manifest_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"item_key": {
"name": "item_key",
"type": "text",
"primaryKey": false,
"notNull": true
},
"source_file": {
"name": "source_file",
"type": "text",
"primaryKey": false,
"notNull": true
},
"checksum": {
"name": "checksum",
"type": "text",
"primaryKey": false,
"notNull": true
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'pending'"
},
"imported_count": {
"name": "imported_count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"updated_count": {
"name": "updated_count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"skipped_count": {
"name": "skipped_count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"error_count": {
"name": "error_count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"warning_count": {
"name": "warning_count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"report_json": {
"name": "report_json",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"seed_manifest_items_manifest_item_idx": {
"name": "seed_manifest_items_manifest_item_idx",
"columns": [
{
"expression": "seed_manifest_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "item_key",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.seed_manifests": {
"name": "seed_manifests",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"setup_run_id": {
"name": "setup_run_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"organization_id": {
"name": "organization_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"version": {
"name": "version",
"type": "text",
"primaryKey": false,
"notNull": true
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"checksum": {
"name": "checksum",
"type": "text",
"primaryKey": false,
"notNull": true
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'pending'"
},
"source": {
"name": "source",
"type": "text",
"primaryKey": false,
"notNull": true
},
"applied_by": {
"name": "applied_by",
"type": "text",
"primaryKey": false,
"notNull": false
},
"applied_at": {
"name": "applied_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"report_json": {
"name": "report_json",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.setup_run_steps": {
"name": "setup_run_steps",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"setup_run_id": {
"name": "setup_run_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"step_key": {
"name": "step_key",
"type": "text",
"primaryKey": false,
"notNull": true
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'not_started'"
},
"input_hash": {
"name": "input_hash",
"type": "text",
"primaryKey": false,
"notNull": false
},
"output_json": {
"name": "output_json",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"errors_json": {
"name": "errors_json",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"warnings_json": {
"name": "warnings_json",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"started_at": {
"name": "started_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"completed_at": {
"name": "completed_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"setup_run_steps_run_step_idx": {
"name": "setup_run_steps_run_step_idx",
"columns": [
{
"expression": "setup_run_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "step_key",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.setup_runs": {
"name": "setup_runs",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'not_started'"
},
"mode": {
"name": "mode",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'production'"
},
"target_organization_id": {
"name": "target_organization_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"started_by": {
"name": "started_by",
"type": "text",
"primaryKey": false,
"notNull": true
},
"completed_by": {
"name": "completed_by",
"type": "text",
"primaryKey": false,
"notNull": false
},
"started_at": {
"name": "started_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"completed_at": {
"name": "completed_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"last_error": {
"name": "last_error",
"type": "text",
"primaryKey": false,
"notNull": false
},
"metadata": {
"name": "metadata",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.tr_audit_logs": { "public.tr_audit_logs": {
"name": "tr_audit_logs", "name": "tr_audit_logs",
"schema": "", "schema": "",

File diff suppressed because it is too large Load Diff

View File

@@ -5,37 +5,9 @@
{ {
"idx": 0, "idx": 0,
"version": "7", "version": "7",
"when": 1782833951256, "when": 1783059567682,
"tag": "0000_short_the_executioner", "tag": "0000_dapper_klaw",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1782896400000,
"tag": "0001_lead_channel_and_status_default",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1782896500000,
"tag": "0002_opportunity_quotation_timeline_hot_project",
"breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1782881325779,
"tag": "0003_curious_wild_child",
"breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1783094400000,
"tag": "0004_setup_state_seed_manifest",
"breakpoints": true "breakpoints": true
} }
] ]
} }

340
plans/task-d.7.11.md Normal file
View File

@@ -0,0 +1,340 @@
# Task D.7.11 First-Run Bootstrap Setup Mode
## Objective
Implement first-run setup mode for ALLA OS.
When the database is newly migrated and the system has not been initialized, the app must automatically redirect users to a public bootstrap setup page without requiring login.
The bootstrap setup page allows uploading required CSV files, validates them, commits setup data, creates the first organization and administrator, marks setup as completed, and then redirects the user to login.
---
## Desired Flow
npm install
npm run db:generate
npm run db:migrate
npm run dev
Open browser
If setup is not completed:
redirect to
/administration/setup
No login required.
User uploads CSV files:
- users.csv
- organizations.csv
- memberships.csv
- master-options.csv
- branches.csv
- product-types.csv
- document-sequences.csv
- approval-workflows.csv
- approval-steps.csv
- approval-matrix.csv
- customers.csv
- contacts.csv
- leads.csv
- opportunities.csv
- quotations.csv
- quotation-items.csv
- quotation-parties.csv
- quotation-topics.csv
- quotation-topic-items.csv
Click Setup
System validates CSV
System commits data
System creates first admin
System marks setup completed
Redirect to login
User logs in and starts using the system
---
## Key Concept
There are now two setup modes:
1. Bootstrap Setup
Used before first login.
Route:
/administration/setup
No login required only when setup is not completed.
2. Admin Setup / Maintenance Setup
Used after login.
Route:
/dashboard/administration/setup
Requires super_admin.
---
## Required Behavior
## First Run Detection
Create setup status check:
System is not initialized when:
- no completed setup_run exists
or
- no super_admin user exists
or
- no organization exists
or
- no admin membership exists
System is initialized when:
- setup_run status = completed
- at least one super_admin exists
- at least one organization exists
- admin membership exists
---
## Middleware Redirect
Update middleware behavior:
If setup is not completed:
allow only:
/administration/setup
/api/setup/bootstrap/*
/api/setup/templates
/_next/*
static assets
redirect all other routes to:
/administration/setup
If setup is completed:
/administration/setup redirects to:
/auth/sign-in
or
/dashboard
---
## Bootstrap Setup Page
Create:
src/app/administration/setup/page.tsx
This page must not use dashboard layout.
It should be a clean public setup page.
Show:
- System status
- Required CSV upload
- Template checklist
- Preview result
- Setup button
- Completion screen
---
## Bootstrap APIs
Create public-but-guarded bootstrap APIs:
GET /api/setup/bootstrap/status
POST /api/setup/bootstrap/preview
POST /api/setup/bootstrap/commit
POST /api/setup/bootstrap/complete
These APIs do not require login only while setup is incomplete.
After setup is completed, these APIs must reject public access.
---
## Bootstrap Security
Even though no login is required, it must be protected by first-run rules.
Rules:
- Bootstrap APIs are enabled only when setup is incomplete.
- Once completed, bootstrap APIs return 403.
- Commit requires previewHash.
- Commit re-runs preview.
- Commit rejects hash mismatch.
- Setup complete requires successful commit.
- Never expose secrets.
- Passwords from users.csv must be hashed.
- No raw CSV contents are persisted.
Optional safety:
Require BOOTSTRAP_SETUP_TOKEN from .env if present.
If env exists:
User must enter token before setup.
---
## CSV Upload UX
The page should show required groups:
Foundation:
- users.csv
- organizations.csv
- memberships.csv
- master-options.csv
- branches.csv
- product-types.csv
Document / Approval:
- document-sequences.csv
- approval-workflows.csv
- approval-steps.csv
- approval-matrix.csv
Business optional:
- customers.csv
- contacts.csv
- leads.csv
- opportunities.csv
- quotations.csv
- quotation-items.csv
- quotation-parties.csv
- quotation-topics.csv
- quotation-topic-items.csv
Allow partial setup:
Required minimum:
- users.csv
- organizations.csv
- memberships.csv
- master-options.csv
- branches.csv
- product-types.csv
- document-sequences.csv
- approval-workflows.csv
- approval-steps.csv
- approval-matrix.csv
Business CSV can be optional.
---
## Setup Completion
After successful commit:
- create setup_run completed
- create seed_manifests
- mark setup status completed
- redirect to /auth/sign-in
Show message:
Setup completed. Please login with the administrator account from users.csv.
---
## Reuse Existing D.7 Work
Reuse:
- CSV templates from D.7.3
- CSV preview engine from D.7.4
- CSV commit engine from D.7.5
- setup state from D.7.8
- seed manifest from D.7.8
- plugin profiles from D.7.9
Do not rewrite import engine.
---
## Out of Scope
- Dashboard setup redesign
- New CSV format
- Reset/reseed
- Plugin marketplace
- Production public setup after completed
- Anonymous setup after system is initialized
---
## Acceptance Criteria
- Fresh database redirects to /administration/setup without login.
- /administration/setup works before first login.
- Bootstrap setup page can upload CSV files.
- Preview validates CSV files.
- Setup commit imports required setup data.
- First admin user is created.
- Organization is created.
- Setup run is marked completed.
- User is redirected to login.
- After completion, /administration/setup no longer allows public setup.
- After completion, dashboard setup requires super_admin.
- Existing /dashboard/administration/setup remains available for maintenance.
- No duplicate CSV validation/import logic is created.
- Typecheck passes.
---
## Suggested Test
1. Drop/reset database.
2. Run migration.
3. Run dev server.
4. Open `/`.
5. Confirm redirect to `/administration/setup`.
6. Upload required CSV files.
7. Preview.
8. Commit setup.
9. Confirm redirect to login.
10. Login with admin from CSV.
11. Open dashboard.
12. Confirm CRM works.
13. Try opening `/administration/setup` again.
14. Confirm public setup is blocked.

View File

@@ -0,0 +1,23 @@
import { redirect } from 'next/navigation';
import { BootstrapSetup } from '@/features/setup/components/bootstrap-setup';
import { getBootstrapSetupStatus } from '@/features/setup/server/setup-bootstrap.service';
export const metadata = {
title: 'ALLA OS Setup'
};
export default async function BootstrapSetupPage() {
const status = await getBootstrapSetupStatus();
if (status.initialized) {
redirect('/auth/sign-in');
}
return (
<BootstrapSetup
tokenRequired={status.tokenRequired}
requiredFiles={status.requiredFiles}
optionalFiles={status.optionalFiles}
/>
);
}

View File

@@ -0,0 +1,77 @@
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 {
assertBootstrapRequiredFiles,
assertBootstrapSetupAvailable,
assertBootstrapToken,
BOOTSTRAP_ACTOR_ID,
BootstrapSetupError,
setupStepStatusFromImportReport,
toSerializableRecord
} from '@/features/setup/server/setup-bootstrap.service';
import { saveSetupStep, startSetupRun } from '@/features/setup/server/setup-state.service';
function isFile(value: FormDataEntryValue): value is File {
return typeof value === 'object' && 'arrayBuffer' in value && 'name' in value;
}
function parseBoolean(value: FormDataEntryValue | null): boolean {
if (typeof value !== 'string') return false;
return value.trim().toLowerCase() === 'true';
}
export async function POST(request: NextRequest) {
try {
await assertBootstrapSetupAvailable();
const formData = await request.formData();
assertBootstrapToken(String(formData.get('bootstrapToken') ?? request.headers.get('x-bootstrap-setup-token') ?? ''));
const previewHash = String(formData.get('previewHash') ?? '');
const dryRun = parseBoolean(formData.get('dryRun'));
const uploadedFiles = Array.from(formData.values()).filter(isFile);
if (uploadedFiles.length === 0) {
return NextResponse.json({ message: 'Upload at least one setup CSV file using multipart/form-data.' }, { status: 400 });
}
const files = await Promise.all(
uploadedFiles.map(async (file) => ({
fileName: file.name,
bytes: new Uint8Array(await file.arrayBuffer())
}))
);
assertBootstrapRequiredFiles(files);
await startSetupRun({ actorId: BOOTSTRAP_ACTOR_ID, mode: 'production', targetOrganizationId: null });
const report = await commitSetupCsvImport({
files,
previewHash,
dryRun,
actorId: BOOTSTRAP_ACTOR_ID
});
if (!dryRun && report.status !== 'FAIL') {
await createCsvImportSeedManifest({ files, report, actorId: BOOTSTRAP_ACTOR_ID });
}
await saveSetupStep({
stepKey: 'csv_commit',
status: setupStepStatusFromImportReport(report),
inputHash: report.committedHash,
output: toSerializableRecord(report),
errors: report.errors,
warnings: report.warnings
});
return NextResponse.json(report, { status: report.status === 'FAIL' ? 400 : 200 });
} catch (error) {
if (error instanceof BootstrapSetupError) {
return NextResponse.json({ message: error.message, details: error.details }, { status: error.status });
}
return NextResponse.json({ message: 'Unable commit bootstrap setup CSV import' }, { status: 500 });
}
}

View File

@@ -0,0 +1,64 @@
import { NextRequest, NextResponse } from 'next/server';
import {
assertBootstrapSetupAvailable,
assertBootstrapToken,
BOOTSTRAP_ACTOR_ID,
BootstrapSetupError,
getBootstrapSetupStatus
} from '@/features/setup/server/setup-bootstrap.service';
import { completeSetupRun, getCurrentSetupRun } from '@/features/setup/server/setup-state.service';
export async function POST(request: NextRequest) {
try {
await assertBootstrapSetupAvailable();
const body = (await request.json().catch(() => ({}))) as { bootstrapToken?: string; report?: Record<string, unknown> };
assertBootstrapToken(body.bootstrapToken ?? request.headers.get('x-bootstrap-setup-token') ?? '');
const currentRun = await getCurrentSetupRun();
const commitStep = currentRun.steps.find((step) => step.stepKey === 'csv_commit');
const hasSuccessfulCommit =
currentRun.run?.status === 'csv_imported' ||
currentRun.run?.status === 'verified' ||
commitStep?.status === 'passed' ||
commitStep?.status === 'warning';
if (!hasSuccessfulCommit) {
return NextResponse.json({ message: 'Commit bootstrap setup data before completing setup.' }, { status: 409 });
}
const status = await getBootstrapSetupStatus();
const missingChecks = Object.entries(status.checks)
.filter(([key, passed]) => key !== 'completedSetupRun' && !passed)
.map(([key]) => key);
if (missingChecks.length > 0) {
return NextResponse.json(
{
message: 'Bootstrap setup cannot complete until required setup data exists.',
missingChecks
},
{ status: 409 }
);
}
const result = await completeSetupRun({
actorId: BOOTSTRAP_ACTOR_ID,
report: Object.assign(
{
source: 'bootstrap',
checks: status.checks
},
body.report
)
});
return NextResponse.json(result);
} catch (error) {
if (error instanceof BootstrapSetupError) {
return NextResponse.json({ message: error.message, details: error.details }, { status: error.status });
}
return NextResponse.json({ message: 'Unable complete bootstrap setup' }, { status: 500 });
}
}

View File

@@ -0,0 +1,60 @@
import { NextRequest, NextResponse } from 'next/server';
import { previewSetupCsvImport } from '@/features/setup/server/csv-preview.service';
import {
assertBootstrapRequiredFiles,
assertBootstrapSetupAvailable,
assertBootstrapToken,
BOOTSTRAP_ACTOR_ID,
BootstrapSetupError,
setupStepStatusFromPreview,
toSerializableRecord
} from '@/features/setup/server/setup-bootstrap.service';
import { saveSetupStep, startSetupRun } from '@/features/setup/server/setup-state.service';
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 assertBootstrapSetupAvailable();
const formData = await request.formData();
assertBootstrapToken(String(formData.get('bootstrapToken') ?? request.headers.get('x-bootstrap-setup-token') ?? ''));
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 });
}
const files = await Promise.all(
uploadedFiles.map(async (file) => ({
fileName: file.name,
bytes: new Uint8Array(await file.arrayBuffer())
}))
);
assertBootstrapRequiredFiles(files);
await startSetupRun({ actorId: BOOTSTRAP_ACTOR_ID, mode: 'production', targetOrganizationId: null });
const preview = await previewSetupCsvImport(files);
await saveSetupStep({
stepKey: 'csv_preview',
status: setupStepStatusFromPreview(preview),
inputHash: preview.previewHash,
output: toSerializableRecord(preview),
errors: preview.files.flatMap((file) => file.errors),
warnings: preview.files.flatMap((file) => file.warnings)
});
return NextResponse.json(preview, { status: preview.summary.error > 0 ? 400 : 200 });
} catch (error) {
if (error instanceof BootstrapSetupError) {
return NextResponse.json({ message: error.message, details: error.details }, { status: error.status });
}
return NextResponse.json({ message: 'Unable preview bootstrap setup CSV import' }, { status: 500 });
}
}

View File

@@ -0,0 +1,6 @@
import { NextResponse } from 'next/server';
import { getBootstrapSetupStatus } from '@/features/setup/server/setup-bootstrap.service';
export async function GET() {
return NextResponse.json(await getBootstrapSetupStatus());
}

View File

@@ -1,6 +1,7 @@
import { readFile } from 'node:fs/promises'; import { readFile } from 'node:fs/promises';
import path from 'node:path'; import path from 'node:path';
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { getBootstrapSetupStatus } from '@/features/setup/server/setup-bootstrap.service';
import { getInstallationProfileBundle } from '@/features/setup/plugins/plugin-registry'; import { getInstallationProfileBundle } from '@/features/setup/plugins/plugin-registry';
import { AuthError, requireSystemRole } from '@/lib/auth/session'; import { AuthError, requireSystemRole } from '@/lib/auth/session';
@@ -19,7 +20,12 @@ interface SetupTemplateMetadata {
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
try { try {
await requireSystemRole('super_admin'); const bootstrapStatus = await getBootstrapSetupStatus();
if (bootstrapStatus.initialized) {
await requireSystemRole('super_admin');
}
const profileId = request.nextUrl.searchParams.get('profile') ?? 'crm_demo'; const profileId = request.nextUrl.searchParams.get('profile') ?? 'crm_demo';
const metadataPath = path.join(process.cwd(), 'setup', 'templates', 'templates.json'); const metadataPath = path.join(process.cwd(), 'setup', 'templates', 'templates.json');
const metadata = JSON.parse(await readFile(metadataPath, 'utf8')) as SetupTemplateMetadata; const metadata = JSON.parse(await readFile(metadataPath, 'utf8')) as SetupTemplateMetadata;

View File

@@ -1,7 +1,14 @@
import { auth } from "@/auth"; import { auth } from "@/auth";
import { getBootstrapSetupStatus } from "@/features/setup/server/setup-bootstrap.service";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
export default async function Page() { export default async function Page() {
const setupStatus = await getBootstrapSetupStatus();
if (!setupStatus.initialized) {
return redirect("/administration/setup");
}
const session = await auth(); const session = await auth();
if (!session?.user?.id) { if (!session?.user?.id) {

View File

@@ -0,0 +1,349 @@
'use client';
import { useMemo, useState } from 'react';
import { useRouter } from 'next/navigation';
import { Icons } from '@/components/icons';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import type { CsvPreviewResult, ImportReport } from '@/features/setup/api/types';
interface BootstrapSetupProps {
tokenRequired: boolean;
requiredFiles: string[];
optionalFiles: string[];
}
interface ApiErrorPayload {
message?: string;
details?: {
missingFiles?: string[];
};
}
function isApiErrorPayload(value: unknown): value is ApiErrorPayload {
return Boolean(value && typeof value === 'object');
}
function getFileMap(files: File[]) {
return new Map(files.map((file) => [file.name.toLowerCase(), file]));
}
function getErrorMessage(error: unknown) {
if (error instanceof Error) return error.message;
return 'Request failed.';
}
async function readJsonResponse<T>(response: Response): Promise<T> {
const payload = (await response.json().catch(() => ({}))) as unknown;
if (!response.ok) {
const message = isApiErrorPayload(payload) && payload.message ? payload.message : 'Request failed.';
throw new Error(message);
}
return payload as T;
}
function appendFiles(formData: FormData, files: File[]) {
files.forEach((file) => {
formData.append('files', file, file.name);
});
}
function StatusBadge({ status }: { status: 'PASS' | 'WARNING' | 'FAIL' }) {
const variant = status === 'FAIL' ? 'destructive' : status === 'WARNING' ? 'secondary' : 'default';
return <Badge variant={variant}>{status}</Badge>;
}
export function BootstrapSetup({ tokenRequired, requiredFiles, optionalFiles }: BootstrapSetupProps) {
const router = useRouter();
const [files, setFiles] = useState<File[]>([]);
const [bootstrapToken, setBootstrapToken] = useState('');
const [preview, setPreview] = useState<CsvPreviewResult | null>(null);
const [commitReport, setCommitReport] = useState<ImportReport | null>(null);
const [isPreviewing, setIsPreviewing] = useState(false);
const [isCommitting, setIsCommitting] = useState(false);
const [isCompleting, setIsCompleting] = useState(false);
const [error, setError] = useState<string | null>(null);
const fileMap = useMemo(() => getFileMap(files), [files]);
const missingRequiredFiles = requiredFiles.filter((fileName) => !fileMap.has(fileName.toLowerCase()));
const selectedOptionalCount = optionalFiles.filter((fileName) => fileMap.has(fileName.toLowerCase())).length;
const canPreview = missingRequiredFiles.length === 0 && files.length > 0 && (!tokenRequired || bootstrapToken.length > 0);
const canCommit = Boolean(preview?.previewHash) && preview?.summary.error === 0 && canPreview;
const canComplete = Boolean(commitReport && commitReport.status !== 'FAIL');
function handleFiles(fileList: FileList | null) {
setFiles(Array.from(fileList ?? []));
setPreview(null);
setCommitReport(null);
setError(null);
}
async function runPreview() {
setIsPreviewing(true);
setError(null);
try {
const formData = new FormData();
formData.set('bootstrapToken', bootstrapToken);
appendFiles(formData, files);
const response = await fetch('/api/setup/bootstrap/preview', {
method: 'POST',
body: formData
});
const result = await readJsonResponse<CsvPreviewResult>(response);
setPreview(result);
} catch (requestError) {
setError(getErrorMessage(requestError));
} finally {
setIsPreviewing(false);
}
}
async function runCommit() {
if (!preview?.previewHash) return;
setIsCommitting(true);
setError(null);
try {
const formData = new FormData();
formData.set('bootstrapToken', bootstrapToken);
formData.set('previewHash', preview.previewHash);
appendFiles(formData, files);
const response = await fetch('/api/setup/bootstrap/commit', {
method: 'POST',
body: formData
});
const result = await readJsonResponse<ImportReport>(response);
setCommitReport(result);
} catch (requestError) {
setError(getErrorMessage(requestError));
} finally {
setIsCommitting(false);
}
}
async function completeSetup() {
setIsCompleting(true);
setError(null);
try {
const response = await fetch('/api/setup/bootstrap/complete', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ bootstrapToken })
});
await readJsonResponse(response);
router.replace('/auth/sign-in');
} catch (requestError) {
setError(getErrorMessage(requestError));
} finally {
setIsCompleting(false);
}
}
return (
<main className='min-h-screen bg-background px-4 py-8 md:px-8'>
<div className='mx-auto flex w-full max-w-6xl flex-col gap-6'>
<div className='space-y-2'>
<Badge variant='secondary'>First-run bootstrap</Badge>
<h1 className='text-3xl font-semibold tracking-normal'>ALLA OS Setup</h1>
<p className='max-w-3xl text-sm text-muted-foreground'>
Upload the official setup CSV files, validate them, commit the initial organization data,
then sign in with the administrator account from users.csv.
</p>
</div>
<Alert>
<Icons.lock className='h-4 w-4' />
<AlertTitle>Public only before initialization</AlertTitle>
<AlertDescription>
This bootstrap page is available only until setup is completed. After completion, setup
maintenance moves to the authenticated dashboard administration page.
</AlertDescription>
</Alert>
{error ? (
<Alert variant='destructive'>
<Icons.alertCircle className='h-4 w-4' />
<AlertTitle>Setup request failed</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
) : null}
<div className='grid gap-6 lg:grid-cols-[1fr_360px]'>
<section className='space-y-6'>
<Card>
<CardHeader>
<CardTitle>CSV Upload</CardTitle>
<CardDescription>Required foundation files must be selected before preview.</CardDescription>
</CardHeader>
<CardContent className='space-y-4'>
{tokenRequired ? (
<Input
type='password'
value={bootstrapToken}
onChange={(event) => setBootstrapToken(event.target.value)}
placeholder='Bootstrap setup token'
aria-label='Bootstrap setup token'
/>
) : null}
<Input type='file' multiple accept='.csv,text/csv' onChange={(event) => handleFiles(event.target.files)} />
<div className='grid gap-2 sm:grid-cols-2'>
{files.map((file) => (
<div key={`${file.name}-${file.size}-${file.lastModified}`} className='rounded-md border p-3 text-sm'>
<div className='font-medium'>{file.name}</div>
<div className='text-muted-foreground'>{file.size} bytes</div>
</div>
))}
</div>
{missingRequiredFiles.length > 0 ? (
<Alert>
<Icons.info className='h-4 w-4' />
<AlertTitle>Required files missing</AlertTitle>
<AlertDescription>{missingRequiredFiles.join(', ')}</AlertDescription>
</Alert>
) : null}
<div className='flex flex-wrap gap-2'>
<Button type='button' onClick={runPreview} disabled={!canPreview || isPreviewing}>
{isPreviewing ? (
<Icons.spinner className='mr-2 h-4 w-4 animate-spin' />
) : (
<Icons.upload className='mr-2 h-4 w-4' />
)}
Preview CSV
</Button>
<Button type='button' variant='outline' onClick={runCommit} disabled={!canCommit || isCommitting}>
{isCommitting ? (
<Icons.spinner className='mr-2 h-4 w-4 animate-spin' />
) : (
<Icons.check className='mr-2 h-4 w-4' />
)}
Setup System
</Button>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Preview Result</CardTitle>
<CardDescription>Commit is enabled only when preview has no blocking errors.</CardDescription>
</CardHeader>
<CardContent className='space-y-4'>
{preview ? (
<>
<div className='flex flex-wrap items-center justify-between gap-3'>
<div className='min-w-0'>
<div className='font-medium'>Preview hash</div>
<div className='break-all text-sm text-muted-foreground'>{preview.previewHash}</div>
</div>
<StatusBadge status={preview.summary.error > 0 ? 'FAIL' : preview.summary.warning > 0 ? 'WARNING' : 'PASS'} />
</div>
<div className='grid gap-2 sm:grid-cols-5'>
<Metric label='Files' value={preview.summary.files} />
<Metric label='Rows' value={preview.summary.rows} />
<Metric label='Create' value={preview.summary.create} />
<Metric label='Warning' value={preview.summary.warning} />
<Metric label='Error' value={preview.summary.error} />
</div>
</>
) : (
<EmptyPanel message='Preview has not run yet.' />
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Completion</CardTitle>
<CardDescription>Finish setup after data commit succeeds.</CardDescription>
</CardHeader>
<CardContent className='space-y-4'>
{commitReport ? (
<div className='grid gap-2 sm:grid-cols-5'>
<Metric label='Imported' value={commitReport.summary.imported} />
<Metric label='Updated' value={commitReport.summary.updated} />
<Metric label='Skipped' value={commitReport.summary.skipped} />
<Metric label='Warnings' value={commitReport.summary.warnings} />
<Metric label='Errors' value={commitReport.summary.errors} />
</div>
) : (
<EmptyPanel message='Commit has not run yet.' />
)}
<Button type='button' onClick={completeSetup} disabled={!canComplete || isCompleting}>
{isCompleting ? (
<Icons.spinner className='mr-2 h-4 w-4 animate-spin' />
) : (
<Icons.arrowRight className='mr-2 h-4 w-4' />
)}
Complete and go to login
</Button>
</CardContent>
</Card>
</section>
<aside className='space-y-6'>
<Card>
<CardHeader>
<CardTitle>Required Template Checklist</CardTitle>
<CardDescription>Foundation, document sequence, and approval setup.</CardDescription>
</CardHeader>
<CardContent className='space-y-2'>
{requiredFiles.map((fileName) => (
<TemplateChecklistItem key={fileName} fileName={fileName} selected={fileMap.has(fileName.toLowerCase())} />
))}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Business CSV</CardTitle>
<CardDescription>Optional files selected: {selectedOptionalCount}</CardDescription>
</CardHeader>
<CardContent className='space-y-2'>
{optionalFiles.map((fileName) => (
<TemplateChecklistItem key={fileName} fileName={fileName} selected={fileMap.has(fileName.toLowerCase())} />
))}
</CardContent>
</Card>
</aside>
</div>
</div>
</main>
);
}
function TemplateChecklistItem({ fileName, selected }: { fileName: string; selected: boolean }) {
return (
<div className='flex items-center justify-between gap-3 rounded-md border px-3 py-2 text-sm'>
<span className='min-w-0 truncate'>{fileName}</span>
<Badge variant={selected ? 'default' : 'secondary'}>{selected ? 'Selected' : 'Missing'}</Badge>
</div>
);
}
function Metric({ label, value }: { label: string; value: number | string }) {
return (
<div className='rounded-md border p-3'>
<div className='text-xs font-medium uppercase text-muted-foreground'>{label}</div>
<div className='text-lg font-semibold'>{value}</div>
</div>
);
}
function EmptyPanel({ message }: { message: string }) {
return <div className='rounded-md border border-dashed p-6 text-center text-sm text-muted-foreground'>{message}</div>;
}

View File

@@ -0,0 +1,163 @@
import 'server-only';
import { count, eq } from 'drizzle-orm';
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 type { SetupStepStatus } from './setup-state.service';
export const BOOTSTRAP_ACTOR_ID = 'bootstrap-setup';
export const BOOTSTRAP_REQUIRED_TEMPLATE_FILES = [
'users.csv',
'organizations.csv',
'memberships.csv',
'master-options.csv',
'branches.csv',
'product-types.csv',
'document-sequences.csv',
'approval-workflows.csv',
'approval-steps.csv',
'approval-matrix.csv'
] as const;
export const BOOTSTRAP_OPTIONAL_TEMPLATE_FILES = [
'customers.csv',
'contacts.csv',
'leads.csv',
'opportunities.csv',
'quotations.csv',
'quotation-items.csv',
'quotation-parties.csv',
'quotation-topics.csv',
'quotation-topic-items.csv'
] as const;
export class BootstrapSetupError extends Error {
status: number;
details?: Record<string, unknown>;
constructor(message: string, status = 400, details?: Record<string, unknown>) {
super(message);
this.name = 'BootstrapSetupError';
this.status = status;
this.details = details;
}
}
export interface BootstrapSetupStatus {
initialized: boolean;
tokenRequired: boolean;
checks: {
completedSetupRun: boolean;
superAdminUser: boolean;
organization: boolean;
adminMembership: boolean;
};
requiredFiles: string[];
optionalFiles: string[];
}
async function hasRows<T>(query: Promise<Array<T & { value: number }>>) {
const [row] = await query;
return Number(row?.value ?? 0) > 0;
}
export async function getBootstrapSetupStatus(): Promise<BootstrapSetupStatus> {
const [completedSetupRun, superAdminUser, organization, adminMembership] = await Promise.all([
hasRows(
db
.select({ value: count() })
.from(setupRuns)
.where(eq(setupRuns.status, 'completed'))
),
hasRows(
db
.select({ value: count() })
.from(users)
.where(eq(users.systemRole, 'super_admin'))
),
hasRows(db.select({ value: count() }).from(organizations)),
hasRows(
db
.select({ value: count() })
.from(memberships)
.where(eq(memberships.role, 'admin'))
)
]);
const checks = {
completedSetupRun,
superAdminUser,
organization,
adminMembership
};
return {
initialized: Object.values(checks).every(Boolean),
tokenRequired: Boolean(process.env.BOOTSTRAP_SETUP_TOKEN?.trim()),
checks,
requiredFiles: [...BOOTSTRAP_REQUIRED_TEMPLATE_FILES],
optionalFiles: [...BOOTSTRAP_OPTIONAL_TEMPLATE_FILES]
};
}
export async function assertBootstrapSetupAvailable() {
const status = await getBootstrapSetupStatus();
if (status.initialized) {
throw new BootstrapSetupError('Bootstrap setup is no longer available after setup completion.', 403, {
checks: status.checks
});
}
return status;
}
export function assertBootstrapToken(token: string | null | undefined) {
const expectedToken = process.env.BOOTSTRAP_SETUP_TOKEN?.trim();
if (!expectedToken) return;
if (!token || token !== expectedToken) {
throw new BootstrapSetupError('Bootstrap setup token is invalid.', 403);
}
}
export function getMissingBootstrapRequiredFiles(files: CsvPreviewFileInput[]) {
const uploaded = new Set(files.map((file) => file.fileName.trim().toLowerCase()));
return BOOTSTRAP_REQUIRED_TEMPLATE_FILES.filter((fileName) => !uploaded.has(fileName));
}
export function assertBootstrapRequiredFiles(files: CsvPreviewFileInput[]) {
const missingFiles = getMissingBootstrapRequiredFiles(files);
if (missingFiles.length > 0) {
throw new BootstrapSetupError('Upload all required setup CSV files before running bootstrap setup.', 400, {
missingFiles
});
}
}
export function setupStepStatusFromReportStatus(status: 'PASS' | 'WARNING' | 'FAIL'): SetupStepStatus {
if (status === 'PASS') return 'passed';
if (status === 'WARNING') return 'warning';
return 'failed';
}
export function setupStepStatusFromImportReport(report: ImportReport): SetupStepStatus {
if (report.status === 'FAIL') return 'failed';
if (report.status === 'WARNING') return 'warning';
return 'passed';
}
export function setupStepStatusFromPreview(preview: CsvPreviewResult): SetupStepStatus {
if (preview.summary.error > 0 || preview.files.some((file) => file.status === 'FAIL')) return 'failed';
if (preview.summary.warning > 0 || preview.files.some((file) => file.status === 'WARNING')) return 'warning';
return 'passed';
}
export function toSerializableRecord(value: unknown): Record<string, unknown> {
return JSON.parse(JSON.stringify(value)) as Record<string, unknown>;
}

View File

@@ -1,56 +1,89 @@
import { auth } from "@/auth"; import { auth } from '@/auth';
import { NextResponse } from "next/server"; import { NextResponse } from 'next/server';
const protectedApiPrefixes = [ const protectedApiPrefixes = ['/api/products', '/api/organizations', '/api/users'];
"/api/products", const superAdminOnlyRoutes = ['/dashboard/workspaces', '/dashboard/administration/setup'];
"/api/organizations", const organizationAdminRoutes = ['/dashboard/users', '/dashboard/workspaces/team'];
"/api/users",
];
const superAdminOnlyRoutes = ["/dashboard/workspaces"];
const organizationAdminRoutes = [
"/dashboard/users",
"/dashboard/workspaces/team",
];
export default auth((req) => { interface BootstrapStatusResponse {
const pathname = req.nextUrl.pathname; initialized: boolean;
const isDashboardRoute = pathname.startsWith("/dashboard/crm"); }
const isProtectedApiRoute = protectedApiPrefixes.some((prefix) =>
pathname.startsWith(prefix), function isBootstrapAllowedPath(pathname: string) {
return (
pathname === '/administration/setup' ||
pathname.startsWith('/api/setup/bootstrap/') ||
pathname === '/api/setup/templates'
); );
}
async function getBootstrapInitialized(origin: string): Promise<boolean | null> {
try {
const response = await fetch(new URL('/api/setup/bootstrap/status', origin), {
cache: 'no-store',
headers: {
'x-setup-proxy-check': '1'
}
});
if (!response.ok) return null;
const payload = (await response.json()) as BootstrapStatusResponse;
return payload.initialized;
} catch {
return null;
}
}
export default auth(async (req) => {
const pathname = req.nextUrl.pathname;
const bootstrapAllowedPath = isBootstrapAllowedPath(pathname);
if (bootstrapAllowedPath) {
return NextResponse.next();
}
const isInitialized = await getBootstrapInitialized(req.nextUrl.origin);
if (isInitialized === false) {
if (pathname.startsWith('/api/')) {
return NextResponse.json(
{ message: 'System setup is required before this API is available.', redirectTo: '/administration/setup' },
{ status: 503 }
);
}
return NextResponse.redirect(new URL('/administration/setup', req.nextUrl.origin));
}
const isDashboardRoute = pathname.startsWith('/dashboard');
const isProtectedApiRoute = protectedApiPrefixes.some((prefix) => pathname.startsWith(prefix));
const user = req.auth?.user; const user = req.auth?.user;
const isSignedIn = !!user; const isSignedIn = Boolean(user);
const isSuperAdmin = user?.systemRole === "super_admin"; const isSuperAdmin = user?.systemRole === 'super_admin';
const isOrganizationAdmin = const isOrganizationAdmin =
isSuperAdmin || isSuperAdmin ||
(user?.activeOrganizationId && Boolean(
(user.activeMembershipRole === "admin" || user?.activeOrganizationId &&
user.activePermissions?.includes("users:manage"))); (user.activeMembershipRole === 'admin' || user.activePermissions?.includes('users:manage'))
);
if (!isSignedIn && (isDashboardRoute || isProtectedApiRoute)) { if (!isSignedIn && (isDashboardRoute || isProtectedApiRoute)) {
if (isProtectedApiRoute) { if (isProtectedApiRoute) {
return NextResponse.json({ message: "Unauthorized" }, { status: 401 }); return NextResponse.json({ message: 'Unauthorized' }, { status: 401 });
} }
const signInUrl = new URL("/auth/sign-in", req.nextUrl.origin); const signInUrl = new URL('/auth/sign-in', req.nextUrl.origin);
signInUrl.searchParams.set("callbackUrl", pathname); signInUrl.searchParams.set('callbackUrl', `${pathname}${req.nextUrl.search}`);
return NextResponse.redirect(signInUrl); return NextResponse.redirect(signInUrl);
} }
if ( if (isSignedIn && superAdminOnlyRoutes.some((prefix) => pathname.startsWith(prefix)) && !isSuperAdmin) {
isSignedIn && return NextResponse.redirect(new URL('/dashboard', req.nextUrl.origin));
superAdminOnlyRoutes.some((prefix) => pathname.startsWith(prefix)) &&
!isSuperAdmin
) {
return NextResponse.redirect(new URL("/dashboard", req.nextUrl.origin));
} }
if ( if (isSignedIn && organizationAdminRoutes.some((prefix) => pathname.startsWith(prefix)) && !isOrganizationAdmin) {
isSignedIn && return NextResponse.redirect(new URL('/dashboard', req.nextUrl.origin));
organizationAdminRoutes.some((prefix) => pathname.startsWith(prefix)) &&
!isOrganizationAdmin
) {
return NextResponse.redirect(new URL("/dashboard", req.nextUrl.origin));
} }
return NextResponse.next(); return NextResponse.next();
@@ -58,7 +91,6 @@ export default auth((req) => {
export const config = { export const config = {
matcher: [ matcher: [
"/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)", '/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)'
"/(api|trpc)(.*)", ]
],
}; };

View File

@@ -0,0 +1,56 @@
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', () => {
for (const route of ['status', 'preview', 'commit', 'complete']) {
assertFileExists(`src/app/api/setup/bootstrap/${route}/route.ts`);
}
assertFileExists('src/app/administration/setup/page.tsx');
assertFileExists('src/features/setup/components/bootstrap-setup.tsx');
assertFileExists('src/features/setup/server/setup-bootstrap.service.ts');
});
test('bootstrap setup guards public access after initialization', () => {
const service = readText('src/features/setup/server/setup-bootstrap.service.ts');
const templatesRoute = readText('src/app/api/setup/templates/route.ts');
const proxy = readText('src/proxy.ts');
assert.match(service, /completedSetupRun/);
assert.match(service, /superAdminUser/);
assert.match(service, /adminMembership/);
assert.match(service, /BOOTSTRAP_SETUP_TOKEN/);
assert.match(service, /Bootstrap setup is no longer available/);
assert.match(templatesRoute, /getBootstrapSetupStatus/);
assert.match(templatesRoute, /requireSystemRole\('super_admin'\)/);
assert.match(proxy, /\/administration\/setup/);
assert.match(proxy, /\/api\/setup\/bootstrap\//);
assert.match(proxy, /\/api\/setup\/templates/);
});
test('bootstrap setup enforces required CSV minimum and preview hash 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');
for (const fileName of [
'users.csv',
'organizations.csv',
'memberships.csv',
'master-options.csv',
'branches.csv',
'product-types.csv',
'document-sequences.csv',
'approval-workflows.csv',
'approval-steps.csv',
'approval-matrix.csv'
]) {
assert.match(service, new RegExp(fileName.replace('.', '\\.')));
}
assert.match(previewRoute, /assertBootstrapRequiredFiles/);
assert.match(commitRoute, /previewHash/);
assert.match(commitRoute, /commitSetupCsvImport/);
assert.match(commitRoute, /createCsvImportSeedManifest/);
});