task-d.7.11
This commit is contained in:
@@ -446,11 +446,12 @@ CREATE TABLE "crm_leads" (
|
||||
"description" text,
|
||||
"project_name" text,
|
||||
"project_location" text,
|
||||
"lead_channel" text,
|
||||
"product_type" text,
|
||||
"priority" text,
|
||||
"estimated_value" double precision,
|
||||
"awareness_id" text,
|
||||
"status" text DEFAULT 'new_job' NOT NULL,
|
||||
"status" text NOT NULL,
|
||||
"followup_status" text,
|
||||
"lost_reason" text,
|
||||
"outcome" text DEFAULT 'open' NOT NULL,
|
||||
@@ -486,10 +487,14 @@ CREATE TABLE "crm_opportunities" (
|
||||
"estimated_value" double precision,
|
||||
"chance_percent" integer,
|
||||
"expected_close_date" timestamp with time zone,
|
||||
"project_close_date" timestamp with time zone,
|
||||
"delivery_date" timestamp with time zone,
|
||||
"competitor" text,
|
||||
"source" text,
|
||||
"notes" text,
|
||||
"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,
|
||||
"pipeline_stage" text DEFAULT 'lead' NOT NULL,
|
||||
"closed_at" timestamp with time zone,
|
||||
@@ -687,7 +692,11 @@ CREATE TABLE "crm_quotations" (
|
||||
"tax_amount" double precision DEFAULT 0 NOT NULL,
|
||||
"total_amount" double precision DEFAULT 0 NOT NULL,
|
||||
"chance_percent" integer,
|
||||
"project_close_date" timestamp with time zone,
|
||||
"delivery_date" timestamp with time zone,
|
||||
"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,
|
||||
"salesman_id" text,
|
||||
"is_sent" boolean DEFAULT false NOT NULL,
|
||||
@@ -829,6 +838,70 @@ CREATE TABLE "products" (
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> 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" (
|
||||
"id" text PRIMARY KEY 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 "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 "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");
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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");
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"id": "6eb8d614-2b85-48c3-919e-af8fa13f0fb6",
|
||||
"id": "cdc9d738-9191-4a58-96bc-66ac8bdb7d7f",
|
||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
@@ -3045,6 +3045,12 @@
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"lead_channel": {
|
||||
"name": "lead_channel",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"product_type": {
|
||||
"name": "product_type",
|
||||
"type": "text",
|
||||
@@ -3073,8 +3079,7 @@
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'new_job'"
|
||||
"notNull": true
|
||||
},
|
||||
"followup_status": {
|
||||
"name": "followup_status",
|
||||
@@ -3307,6 +3312,18 @@
|
||||
"primaryKey": 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": {
|
||||
"name": "competitor",
|
||||
"type": "text",
|
||||
@@ -3332,6 +3349,20 @@
|
||||
"notNull": true,
|
||||
"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": {
|
||||
"name": "is_active",
|
||||
"type": "boolean",
|
||||
@@ -4526,6 +4557,18 @@
|
||||
"primaryKey": 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": {
|
||||
"name": "is_hot_project",
|
||||
"type": "boolean",
|
||||
@@ -4533,6 +4576,20 @@
|
||||
"notNull": true,
|
||||
"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": {
|
||||
"name": "competitor",
|
||||
"type": "text",
|
||||
@@ -5576,6 +5633,433 @@
|
||||
"checkConstraints": {},
|
||||
"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": {
|
||||
"name": "tr_audit_logs",
|
||||
"schema": "",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,37 +5,9 @@
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1782833951256,
|
||||
"tag": "0000_short_the_executioner",
|
||||
"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",
|
||||
"when": 1783059567682,
|
||||
"tag": "0000_dapper_klaw",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
340
plans/task-d.7.11.md
Normal file
340
plans/task-d.7.11.md
Normal 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.
|
||||
23
src/app/administration/setup/page.tsx
Normal file
23
src/app/administration/setup/page.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
77
src/app/api/setup/bootstrap/commit/route.ts
Normal file
77
src/app/api/setup/bootstrap/commit/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
64
src/app/api/setup/bootstrap/complete/route.ts
Normal file
64
src/app/api/setup/bootstrap/complete/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
60
src/app/api/setup/bootstrap/preview/route.ts
Normal file
60
src/app/api/setup/bootstrap/preview/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
6
src/app/api/setup/bootstrap/status/route.ts
Normal file
6
src/app/api/setup/bootstrap/status/route.ts
Normal 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());
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getBootstrapSetupStatus } from '@/features/setup/server/setup-bootstrap.service';
|
||||
import { getInstallationProfileBundle } from '@/features/setup/plugins/plugin-registry';
|
||||
import { AuthError, requireSystemRole } from '@/lib/auth/session';
|
||||
|
||||
@@ -19,7 +20,12 @@ interface SetupTemplateMetadata {
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
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 metadataPath = path.join(process.cwd(), 'setup', 'templates', 'templates.json');
|
||||
const metadata = JSON.parse(await readFile(metadataPath, 'utf8')) as SetupTemplateMetadata;
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { auth } from "@/auth";
|
||||
import { getBootstrapSetupStatus } from "@/features/setup/server/setup-bootstrap.service";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default async function Page() {
|
||||
const setupStatus = await getBootstrapSetupStatus();
|
||||
|
||||
if (!setupStatus.initialized) {
|
||||
return redirect("/administration/setup");
|
||||
}
|
||||
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user?.id) {
|
||||
|
||||
349
src/features/setup/components/bootstrap-setup.tsx
Normal file
349
src/features/setup/components/bootstrap-setup.tsx
Normal 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>;
|
||||
}
|
||||
163
src/features/setup/server/setup-bootstrap.service.ts
Normal file
163
src/features/setup/server/setup-bootstrap.service.ts
Normal 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>;
|
||||
}
|
||||
112
src/proxy.ts
112
src/proxy.ts
@@ -1,56 +1,89 @@
|
||||
import { auth } from "@/auth";
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from '@/auth';
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
const protectedApiPrefixes = [
|
||||
"/api/products",
|
||||
"/api/organizations",
|
||||
"/api/users",
|
||||
];
|
||||
const superAdminOnlyRoutes = ["/dashboard/workspaces"];
|
||||
const organizationAdminRoutes = [
|
||||
"/dashboard/users",
|
||||
"/dashboard/workspaces/team",
|
||||
];
|
||||
const protectedApiPrefixes = ['/api/products', '/api/organizations', '/api/users'];
|
||||
const superAdminOnlyRoutes = ['/dashboard/workspaces', '/dashboard/administration/setup'];
|
||||
const organizationAdminRoutes = ['/dashboard/users', '/dashboard/workspaces/team'];
|
||||
|
||||
export default auth((req) => {
|
||||
const pathname = req.nextUrl.pathname;
|
||||
const isDashboardRoute = pathname.startsWith("/dashboard/crm");
|
||||
const isProtectedApiRoute = protectedApiPrefixes.some((prefix) =>
|
||||
pathname.startsWith(prefix),
|
||||
interface BootstrapStatusResponse {
|
||||
initialized: boolean;
|
||||
}
|
||||
|
||||
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 isSignedIn = !!user;
|
||||
const isSuperAdmin = user?.systemRole === "super_admin";
|
||||
const isSignedIn = Boolean(user);
|
||||
const isSuperAdmin = user?.systemRole === 'super_admin';
|
||||
const isOrganizationAdmin =
|
||||
isSuperAdmin ||
|
||||
(user?.activeOrganizationId &&
|
||||
(user.activeMembershipRole === "admin" ||
|
||||
user.activePermissions?.includes("users:manage")));
|
||||
Boolean(
|
||||
user?.activeOrganizationId &&
|
||||
(user.activeMembershipRole === 'admin' || user.activePermissions?.includes('users:manage'))
|
||||
);
|
||||
|
||||
if (!isSignedIn && (isDashboardRoute || isProtectedApiRoute)) {
|
||||
if (isProtectedApiRoute) {
|
||||
return NextResponse.json({ message: "Unauthorized" }, { status: 401 });
|
||||
return NextResponse.json({ message: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const signInUrl = new URL("/auth/sign-in", req.nextUrl.origin);
|
||||
signInUrl.searchParams.set("callbackUrl", pathname);
|
||||
const signInUrl = new URL('/auth/sign-in', req.nextUrl.origin);
|
||||
signInUrl.searchParams.set('callbackUrl', `${pathname}${req.nextUrl.search}`);
|
||||
return NextResponse.redirect(signInUrl);
|
||||
}
|
||||
|
||||
if (
|
||||
isSignedIn &&
|
||||
superAdminOnlyRoutes.some((prefix) => pathname.startsWith(prefix)) &&
|
||||
!isSuperAdmin
|
||||
) {
|
||||
return NextResponse.redirect(new URL("/dashboard", req.nextUrl.origin));
|
||||
if (isSignedIn && superAdminOnlyRoutes.some((prefix) => pathname.startsWith(prefix)) && !isSuperAdmin) {
|
||||
return NextResponse.redirect(new URL('/dashboard', req.nextUrl.origin));
|
||||
}
|
||||
|
||||
if (
|
||||
isSignedIn &&
|
||||
organizationAdminRoutes.some((prefix) => pathname.startsWith(prefix)) &&
|
||||
!isOrganizationAdmin
|
||||
) {
|
||||
return NextResponse.redirect(new URL("/dashboard", req.nextUrl.origin));
|
||||
if (isSignedIn && organizationAdminRoutes.some((prefix) => pathname.startsWith(prefix)) && !isOrganizationAdmin) {
|
||||
return NextResponse.redirect(new URL('/dashboard', req.nextUrl.origin));
|
||||
}
|
||||
|
||||
return NextResponse.next();
|
||||
@@ -58,7 +91,6 @@ export default auth((req) => {
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
"/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)",
|
||||
"/(api|trpc)(.*)",
|
||||
],
|
||||
'/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)'
|
||||
]
|
||||
};
|
||||
|
||||
56
src/tests/setup/setup-bootstrap.test.ts
Normal file
56
src/tests/setup/setup-bootstrap.test.ts
Normal 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/);
|
||||
});
|
||||
Reference in New Issue
Block a user