@habeetat/cli 0.1.0-dev.20260330090152.e7723de → 0.1.0-dev.20260330110637.de8c473

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/bin.js CHANGED
@@ -52,6 +52,7 @@ var SERVICES = [
52
52
  "launcher",
53
53
  "organization-manager",
54
54
  "platform-manager",
55
+ "analytics",
55
56
  "sample-crm",
56
57
  "nginx",
57
58
  "zookeeper",
@@ -258,6 +259,7 @@ function buildSeedLogtoAppsSql(env) {
258
259
  const launcherUrl = env.LAUNCHER_URL || `${protocol}://launcher.${domain}`;
259
260
  const orgManagerUrl = env.ORG_MANAGER_URL || `${protocol}://organization-manager.${domain}`;
260
261
  const platformManagerUrl = env.PLATFORM_MANAGER_URL || `${protocol}://platform-manager.${domain}`;
262
+ const analyticsUrl = env.ANALYTICS_URL || `${protocol}://analytics.${domain}`;
261
263
  const sampleCrmUrl = env.SAMPLE_CRM_URL || `${protocol}://sample-crm.${domain}`;
262
264
  const apiResource = env.IAM_AUDIENCE || `${protocol}://api.${domain}/api`;
263
265
  const m2mAppId = "nhp-m2m-config";
@@ -300,6 +302,14 @@ VALUES (
300
302
  '{}'::jsonb, false
301
303
  ) ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, description = EXCLUDED.description, type = EXCLUDED.type, oidc_client_metadata = EXCLUDED.oidc_client_metadata;
302
304
 
305
+ -- Analytics SPA
306
+ INSERT INTO applications (tenant_id, id, name, secret, description, type, oidc_client_metadata, custom_client_metadata, is_third_party)
307
+ VALUES (
308
+ 'default', 'nhp-analytics', 'Habeetat Analytics', 'spa-secret-nhp-analytics', 'Platform analytics dashboard', 'SPA',
309
+ jsonb_build_object('redirectUris', ARRAY['${analyticsUrl}', '${analyticsUrl}/callback'], 'postLogoutRedirectUris', ARRAY['${analyticsUrl}']),
310
+ '{}'::jsonb, false
311
+ ) ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, description = EXCLUDED.description, type = EXCLUDED.type, oidc_client_metadata = EXCLUDED.oidc_client_metadata;
312
+
303
313
  -- Sample CRM SPA
304
314
  INSERT INTO applications (tenant_id, id, name, secret, description, type, oidc_client_metadata, custom_client_metadata, is_third_party)
305
315
  VALUES (
@@ -364,6 +374,7 @@ VALUES
364
374
  ('app_launcher', 'launcher', 'Habeetat Launcher', 'Main launcher and navigation hub', 'system', true, true, true, true, 'PUBLISHED', 'nhp-launcher', NOW(), NOW()),
365
375
  ('app_org_manager', 'organization-manager', 'Organization Manager', 'Manage organizations, users and roles', 'system', true, true, false, true, 'PUBLISHED', 'nhp-org-manager', NOW(), NOW()),
366
376
  ('app_plat_manager', 'platform-manager', 'Platform Manager', 'Platform administration and configuration', 'system', true, true, true, false, 'PUBLISHED', 'nhp-plat-mgr', NOW(), NOW()),
377
+ ('app_analytics', 'analytics', 'Habeetat Analytics', 'Platform analytics dashboard', 'system', true, true, true, false, 'PUBLISHED', 'nhp-analytics', NOW(), NOW()),
367
378
  ('app_sample_crm', 'sample-crm', 'Sample CRM', 'Sample CRM application for demonstration', 'demo', true, false, false, false, 'PUBLISHED', 'nhp-sample-crm', NOW(), NOW())
368
379
  ON CONFLICT (slug) DO UPDATE SET
369
380
  name = EXCLUDED.name,
@@ -380,6 +391,7 @@ VALUES
380
391
  ('ver_launcher_100', 'app_launcher', '1.0.0', jsonb_build_object('endpoints', jsonb_build_object('main', '${protocol}://launcher.${domain}')), 'ACTIVE', NOW()),
381
392
  ('ver_org_manager_100', 'app_org_manager', '1.0.0', jsonb_build_object('endpoints', jsonb_build_object('main', '${protocol}://organization-manager.${domain}')), 'ACTIVE', NOW()),
382
393
  ('ver_plat_manager_100', 'app_plat_manager', '1.0.0', jsonb_build_object('endpoints', jsonb_build_object('main', '${protocol}://platform-manager.${domain}')), 'ACTIVE', NOW()),
394
+ ('ver_analytics_100', 'app_analytics', '1.0.0', jsonb_build_object('endpoints', jsonb_build_object('main', '${protocol}://analytics.${domain}')), 'ACTIVE', NOW()),
383
395
  ('ver_sample_crm_100', 'app_sample_crm', '1.0.0', jsonb_build_object('endpoints', jsonb_build_object('main', '${protocol}://sample-crm.${domain}')), 'ACTIVE', NOW())
384
396
  ON CONFLICT (app_id, version) DO UPDATE SET manifest = EXCLUDED.manifest;
385
397
 
@@ -455,6 +467,145 @@ JOIN application_versions v ON v.app_id = a.id AND v.version = '1.0.0'
455
467
  WHERE t.slug = '${orgSlug}'
456
468
  ON CONFLICT (tenant_id, app_id) DO NOTHING;`;
457
469
  }
470
+ function buildClickhouseAnalyticsSeedSql(tenantId, adminUserId) {
471
+ const now = /* @__PURE__ */ new Date();
472
+ const rows = [];
473
+ const users = [adminUserId, "usr_demo_001", "usr_demo_002", "usr_demo_003", "usr_demo_004"];
474
+ const events = [
475
+ { name: "page_view", props: (page) => `{"page":"${page}","referrer":"direct"}` },
476
+ { name: "user_login", props: () => `{"method":"password"}` },
477
+ { name: "feature_used", props: (f) => `{"feature":"${f}"}` },
478
+ { name: "report_created", props: () => `{"type":"insight"}` },
479
+ { name: "dashboard_viewed", props: () => `{"dashboard":"main"}` },
480
+ { name: "user_logout", props: () => `{}` }
481
+ ];
482
+ const pages = ["/dashboard", "/analytics", "/users", "/settings", "/reports"];
483
+ const features = ["insights", "funnels", "retention", "user-feed", "dashboards"];
484
+ for (let day = 29; day >= 0; day--) {
485
+ const date = new Date(now);
486
+ date.setDate(date.getDate() - day);
487
+ const eventsPerDay = 4 + Math.floor(Math.random() * 10);
488
+ for (let i = 0; i < eventsPerDay; i++) {
489
+ const userId = users[Math.floor(Math.random() * users.length)];
490
+ const hour = 8 + Math.floor(Math.random() * 10);
491
+ const minute = Math.floor(Math.random() * 60);
492
+ const ts = `${date.toISOString().split("T")[0]} ${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}:00`;
493
+ const sessionId = `sess_${day}_${i}`;
494
+ if (i === 0) {
495
+ rows.push(`('${tenantId}','${userId}','${sessionId}','user_login','{"method":"password"}','${ts}')`);
496
+ }
497
+ const eventIdx = Math.floor(Math.random() * events.length);
498
+ const ev = events[eventIdx];
499
+ let props = "";
500
+ if (ev.name === "page_view") props = ev.props(pages[Math.floor(Math.random() * pages.length)]);
501
+ else if (ev.name === "feature_used") props = ev.props(features[Math.floor(Math.random() * features.length)]);
502
+ else props = ev.props();
503
+ rows.push(`('${tenantId}','${userId}','${sessionId}','${ev.name}','${props}','${ts}')`);
504
+ }
505
+ }
506
+ return `INSERT INTO nhp_analytics.events (tenant_id, user_id, session_id, event_name, properties, timestamp) VALUES ${rows.join(",\n")};`;
507
+ }
508
+ function buildAnalyticsObjectsSql(tenantId, adminUserId) {
509
+ return `
510
+ -- Analytics dashboard
511
+ INSERT INTO analytics_dashboards (id, tenant_id, name, description, layout, created_by, created_at, updated_at)
512
+ VALUES (
513
+ 'dash_default_${tenantId.replace(/-/g, "_")}',
514
+ '${tenantId}',
515
+ 'Overview',
516
+ 'Main analytics dashboard',
517
+ '[{"i":"ins_pv","x":0,"y":0,"w":6,"h":3},{"i":"ins_fl","x":6,"y":0,"w":6,"h":3},{"i":"ins_fu","x":0,"y":3,"w":12,"h":3}]',
518
+ '${adminUserId}',
519
+ NOW(), NOW()
520
+ ) ON CONFLICT (id) DO NOTHING;
521
+
522
+ -- Insight: Page views trend
523
+ INSERT INTO analytics_insights (id, tenant_id, name, type, config, created_by, created_at, updated_at)
524
+ VALUES (
525
+ 'ins_pv',
526
+ '${tenantId}',
527
+ 'Page Views (last 30d)',
528
+ 'TREND',
529
+ '{"eventName":"page_view","aggregation":"count","interval":"day","filters":[]}',
530
+ '${adminUserId}',
531
+ NOW(), NOW()
532
+ ) ON CONFLICT (id) DO NOTHING;
533
+
534
+ -- Insight: Feature usage breakdown
535
+ INSERT INTO analytics_insights (id, tenant_id, name, type, config, created_by, created_at, updated_at)
536
+ VALUES (
537
+ 'ins_fl',
538
+ '${tenantId}',
539
+ 'Feature Usage Breakdown',
540
+ 'PIE',
541
+ '{"eventName":"feature_used","aggregation":"count","interval":"day","groupBy":"feature","filters":[]}',
542
+ '${adminUserId}',
543
+ NOW(), NOW()
544
+ ) ON CONFLICT (id) DO NOTHING;
545
+
546
+ -- Insight: Unique active users
547
+ INSERT INTO analytics_insights (id, tenant_id, name, type, config, created_by, created_at, updated_at)
548
+ VALUES (
549
+ 'ins_fu',
550
+ '${tenantId}',
551
+ 'Unique Active Users (last 30d)',
552
+ 'TREND',
553
+ '{"eventName":"user_login","aggregation":"count_unique","interval":"day","filters":[]}',
554
+ '${adminUserId}',
555
+ NOW(), NOW()
556
+ ) ON CONFLICT (id) DO NOTHING;
557
+
558
+ -- Funnel: Login \u2192 Feature \u2192 Report
559
+ INSERT INTO analytics_funnels (id, tenant_id, name, steps, window_seconds, created_by, created_at, updated_at)
560
+ VALUES (
561
+ 'fun_onboarding_${tenantId.replace(/-/g, "_")}',
562
+ '${tenantId}',
563
+ 'Login \u2192 Feature \u2192 Report',
564
+ '[{"eventName":"user_login"},{"eventName":"feature_used"},{"eventName":"report_created"}]',
565
+ 86400,
566
+ '${adminUserId}',
567
+ NOW(), NOW()
568
+ ) ON CONFLICT (id) DO NOTHING;
569
+
570
+ -- Funnel: Login \u2192 Dashboard \u2192 Feature
571
+ INSERT INTO analytics_funnels (id, tenant_id, name, steps, window_seconds, created_by, created_at, updated_at)
572
+ VALUES (
573
+ 'fun_engagement_${tenantId.replace(/-/g, "_")}',
574
+ '${tenantId}',
575
+ 'Login \u2192 Dashboard \u2192 Feature',
576
+ '[{"eventName":"user_login"},{"eventName":"dashboard_viewed"},{"eventName":"feature_used"}]',
577
+ 3600,
578
+ '${adminUserId}',
579
+ NOW(), NOW()
580
+ ) ON CONFLICT (id) DO NOTHING;
581
+
582
+ -- Retention: Login \u2192 return login (weekly)
583
+ INSERT INTO analytics_retentions (id, tenant_id, name, start_event, return_event, period, created_by, created_at, updated_at)
584
+ VALUES (
585
+ 'ret_login_${tenantId.replace(/-/g, "_")}',
586
+ '${tenantId}',
587
+ 'Weekly Login Retention',
588
+ 'user_login',
589
+ 'user_login',
590
+ 'WEEK',
591
+ '${adminUserId}',
592
+ NOW(), NOW()
593
+ ) ON CONFLICT (id) DO NOTHING;
594
+
595
+ -- Retention: Feature adoption (daily)
596
+ INSERT INTO analytics_retentions (id, tenant_id, name, start_event, return_event, period, created_by, created_at, updated_at)
597
+ VALUES (
598
+ 'ret_feature_${tenantId.replace(/-/g, "_")}',
599
+ '${tenantId}',
600
+ 'Feature Adoption Retention',
601
+ 'feature_used',
602
+ 'feature_used',
603
+ 'DAY',
604
+ '${adminUserId}',
605
+ NOW(), NOW()
606
+ ) ON CONFLICT (id) DO NOTHING;
607
+ `;
608
+ }
458
609
  async function initPlatform(projectDir) {
459
610
  const config = loadConfig(projectDir);
460
611
  const env = loadEnv(projectDir);
@@ -481,6 +632,23 @@ async function initPlatform(projectDir) {
481
632
  throw new Error("platform-db timeout");
482
633
  }
483
634
  dbSpinner.succeed("Databases ready");
635
+ const clickhouseSpinner = ora__default.default("Initializing ClickHouse analytics schema...").start();
636
+ try {
637
+ const clickhouseSql = [
638
+ "CREATE DATABASE IF NOT EXISTS nhp_analytics",
639
+ `CREATE TABLE IF NOT EXISTS nhp_analytics.events (event_id UUID DEFAULT generateUUIDv4(), tenant_id String, user_id String, session_id String DEFAULT '', event_name LowCardinality(String), properties String DEFAULT '{}', timestamp DateTime64(3, 'UTC'), inserted_at DateTime DEFAULT now()) ENGINE = MergeTree PARTITION BY toYYYYMM(timestamp) ORDER BY (tenant_id, event_name, timestamp, user_id) TTL toDateTime(timestamp) + INTERVAL 365 DAY DELETE SETTINGS index_granularity = 8192`,
640
+ "ALTER TABLE nhp_analytics.events ADD INDEX IF NOT EXISTS idx_user_id user_id TYPE bloom_filter(0.01) GRANULARITY 4",
641
+ "ALTER TABLE nhp_analytics.events ADD INDEX IF NOT EXISTS idx_session_id session_id TYPE bloom_filter(0.01) GRANULARITY 4"
642
+ ];
643
+ for (const sql of clickhouseSql) {
644
+ await execCompose(projectDir, `exec -T clickhouse clickhouse-client --query "${sql}"`);
645
+ }
646
+ clickhouseSpinner.succeed("ClickHouse analytics schema initialized");
647
+ } catch (err) {
648
+ clickhouseSpinner.warn("ClickHouse schema initialization failed \u2014 analytics may not work");
649
+ logger.error(String(err));
650
+ logger.info('Run manually: docker compose exec clickhouse clickhouse-client --query "CREATE DATABASE IF NOT EXISTS nhp_analytics"');
651
+ }
484
652
  const logtoSpinner = ora__default.default("Waiting for Logto to be healthy (this may take up to 2 min)...").start();
485
653
  const logtoReady = await waitForService(
486
654
  projectDir,
@@ -630,6 +798,32 @@ async function initPlatform(projectDir) {
630
798
  logger.error(String(err));
631
799
  logger.info("You may need to initialize the platform manually after first login");
632
800
  }
801
+ const analyticsSpinner = ora__default.default("Seeding analytics demo data...").start();
802
+ try {
803
+ const orgName = env.ORGANIZATION_NAME || config.platform.organization || "Default";
804
+ const orgSlug = orgName.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, "");
805
+ const adminUserId = "usr_plat_admin01";
806
+ const chSql = buildClickhouseAnalyticsSeedSql(orgSlug, adminUserId);
807
+ await execa.execa("docker", [
808
+ "compose",
809
+ "-f",
810
+ COMPOSE_FILE,
811
+ "exec",
812
+ "-T",
813
+ "clickhouse",
814
+ "clickhouse-client",
815
+ "--multiquery"
816
+ ], { cwd: projectDir, input: chSql, stdio: ["pipe", "pipe", "pipe"] });
817
+ const pgSql = buildAnalyticsObjectsSql(orgSlug, adminUserId);
818
+ await execa.execaCommand(
819
+ `docker compose -f ${COMPOSE_FILE} exec -T platform-db psql -U ${env.PLATFORM_DB_USER || "habeetat"} -d ${env.PLATFORM_DB_NAME || "habeetat_platform"}`,
820
+ { cwd: projectDir, input: pgSql }
821
+ );
822
+ analyticsSpinner.succeed("Analytics demo data seeded");
823
+ } catch (err) {
824
+ analyticsSpinner.warn("Analytics demo data seeding failed \u2014 app will start empty");
825
+ logger.error(String(err));
826
+ }
633
827
  if (config.services.signoz) {
634
828
  const signozUserSpinner = ora__default.default("Creating SigNoz admin user...").start();
635
829
  try {
@@ -716,6 +910,9 @@ async function upCommand() {
716
910
  console.log(` Launcher: ${proto}://launcher.${domain}`);
717
911
  console.log(` Organization Manager: ${proto}://organization-manager.${domain}`);
718
912
  console.log(` Platform Manager: ${proto}://platform-manager.${domain}`);
913
+ if (config.services.analytics) {
914
+ console.log(` Analytics: ${proto}://analytics.${domain}`);
915
+ }
719
916
  console.log(` Backend API: ${proto}://api.${domain}`);
720
917
  console.log(` Logto IAM: ${proto}://iam.${domain}`);
721
918
  console.log(` Logto Admin Console: ${proto}://iam-console.${domain}`);