@ahmadubaidillah/core 1.1.3 → 1.1.4
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/index.js +59 -17
- package/dist/plugins/admin-panel/files/src/modules/admin/components/OverviewChart.tsx +56 -0
- package/dist/plugins/admin-panel/files/src/modules/admin/services/admin.service.ts +40 -0
- package/dist/plugins/admin-panel/files/src/modules/admin/views/AdminDashboard.tsx +106 -0
- package/dist/plugins/admin-panel/plugin.config.json +10 -0
- package/dist/plugins/admin-panel/tsconfig.json +12 -0
- package/dist/plugins/analytics/files/src/modules/analytics/services/analytics.service.ts +71 -0
- package/dist/plugins/analytics/plugin.config.json +8 -0
- package/dist/plugins/auth/files/src/modules/auth/auth.schema.ts +21 -0
- package/dist/plugins/auth/files/src/modules/auth/db/rls.ts +31 -0
- package/dist/plugins/auth/files/src/modules/auth/db/schema.ts +103 -0
- package/dist/plugins/auth/files/src/modules/auth/middleware/auth.middleware.ts +12 -0
- package/dist/plugins/auth/files/src/modules/auth/middleware/tenant.middleware.ts +50 -0
- package/dist/plugins/auth/files/src/modules/auth/routes/auth.routes.ts +40 -0
- package/dist/plugins/auth/files/src/modules/auth/services/auth.service.ts +113 -0
- package/dist/plugins/auth/plugin.config.json +9 -0
- package/dist/plugins/cms/files/src/modules/cms/cms.schema.ts +24 -0
- package/dist/plugins/cms/files/src/modules/cms/db/schema.ts +88 -0
- package/dist/plugins/cms/files/src/modules/cms/routes/cms.routes.ts +67 -0
- package/dist/plugins/cms/files/src/modules/cms/services/cms.service.ts +99 -0
- package/dist/plugins/cms/plugin.config.json +9 -0
- package/dist/plugins/deployment/files/Dockerfile +33 -0
- package/dist/plugins/deployment/files/docker-compose.yml +27 -0
- package/dist/plugins/deployment/files/vercel.json +14 -0
- package/dist/plugins/deployment/plugin.config.json +5 -0
- package/dist/plugins/email/files/src/modules/email/services/email.service.ts +30 -0
- package/dist/plugins/email/plugin.config.json +9 -0
- package/dist/plugins/file_upload/files/src/modules/storage/services/storage.service.ts +39 -0
- package/dist/plugins/file_upload/plugin.config.json +10 -0
- package/dist/plugins/github-actions/files/.github/workflows/ci.yml +34 -0
- package/dist/plugins/github-actions/plugin.config.json +14 -0
- package/dist/plugins/openapi/files/src/modules/openapi/openapi.routes.ts +17 -0
- package/dist/plugins/openapi/files/src/modules/openapi/openapi.schema.ts +10 -0
- package/dist/plugins/openapi/plugin.config.json +10 -0
- package/dist/plugins/payments/files/src/modules/billing/billing.schema.ts +14 -0
- package/dist/plugins/payments/files/src/modules/billing/routes/billing.routes.ts +57 -0
- package/dist/plugins/payments/files/src/modules/billing/services/stripe.service.ts +47 -0
- package/dist/plugins/payments/plugin.config.json +10 -0
- package/dist/plugins/queue/files/src/modules/queue/services/queue.service.ts +61 -0
- package/dist/plugins/queue/plugin.config.json +10 -0
- package/dist/plugins/search/files/src/modules/search/services/search.service.ts +98 -0
- package/dist/plugins/search/plugin.config.json +9 -0
- package/dist/plugins/websocket/files/src/modules/websocket/services/ws.service.ts +51 -0
- package/dist/plugins/websocket/plugin.config.json +7 -0
- package/package.json +2 -1
package/dist/index.js
CHANGED
|
@@ -26349,17 +26349,57 @@ var init_plugin_registry = __esm(() => {
|
|
|
26349
26349
|
// packages/core/src/plugins/plugin-installer.ts
|
|
26350
26350
|
var exports_plugin_installer = {};
|
|
26351
26351
|
__export(exports_plugin_installer, {
|
|
26352
|
-
installPlugin: () => installPlugin
|
|
26352
|
+
installPlugin: () => installPlugin,
|
|
26353
|
+
getPluginsRoot: () => getPluginsRoot
|
|
26353
26354
|
});
|
|
26354
26355
|
import { join as join5 } from "path";
|
|
26356
|
+
import { fileURLToPath } from "url";
|
|
26357
|
+
import { dirname } from "path";
|
|
26358
|
+
function getPluginsRoot() {
|
|
26359
|
+
const searchPaths = [
|
|
26360
|
+
join5(__dirname2, "plugins"),
|
|
26361
|
+
join5(__dirname2, "..", "plugins"),
|
|
26362
|
+
join5(__dirname2, "..", "..", "plugins"),
|
|
26363
|
+
join5(process.cwd(), "node_modules", "@ahmadubaidillah", "core", "plugins"),
|
|
26364
|
+
join5(process.cwd(), "packages", "plugins")
|
|
26365
|
+
];
|
|
26366
|
+
for (const p of searchPaths) {
|
|
26367
|
+
if (existsSync4(p)) {
|
|
26368
|
+
if (existsSync4(join5(p, "auth")))
|
|
26369
|
+
return p;
|
|
26370
|
+
}
|
|
26371
|
+
}
|
|
26372
|
+
let current = __dirname2;
|
|
26373
|
+
const root = "/";
|
|
26374
|
+
while (current !== root) {
|
|
26375
|
+
const p = join5(current, "plugins");
|
|
26376
|
+
if (existsSync4(p) && existsSync4(join5(p, "auth"))) {
|
|
26377
|
+
return p;
|
|
26378
|
+
}
|
|
26379
|
+
const parent = dirname(current);
|
|
26380
|
+
if (parent === current)
|
|
26381
|
+
break;
|
|
26382
|
+
current = parent;
|
|
26383
|
+
}
|
|
26384
|
+
return join5(process.cwd(), "packages", "plugins");
|
|
26385
|
+
}
|
|
26355
26386
|
async function installPlugin(pluginName, projectDir, options) {
|
|
26356
|
-
|
|
26387
|
+
const aliases = {
|
|
26388
|
+
payment: "payments",
|
|
26389
|
+
"file-upload": "file_upload",
|
|
26390
|
+
"file-uploads": "file_upload",
|
|
26391
|
+
"github-action": "github-actions",
|
|
26392
|
+
"deployment-vercel": "deployment"
|
|
26393
|
+
};
|
|
26394
|
+
const normalizedName = aliases[pluginName] || pluginName;
|
|
26395
|
+
const pluginsRoot = getPluginsRoot();
|
|
26396
|
+
let pluginPath = join5(pluginsRoot, normalizedName);
|
|
26357
26397
|
if (!existsSync4(pluginPath)) {
|
|
26358
|
-
console.log(`[INSTALL] Local plugin "${
|
|
26398
|
+
console.log(`[INSTALL] Local plugin "${normalizedName}" not found. Checking remote registry...`);
|
|
26359
26399
|
try {
|
|
26360
|
-
pluginPath = await PluginRegistry.downloadPlugin(
|
|
26400
|
+
pluginPath = await PluginRegistry.downloadPlugin(normalizedName);
|
|
26361
26401
|
} catch (e) {
|
|
26362
|
-
throw new Error(`Plugin "${
|
|
26402
|
+
throw new Error(`Plugin "${normalizedName}" not found locally or in the remote registry: ${e.message}`);
|
|
26363
26403
|
}
|
|
26364
26404
|
}
|
|
26365
26405
|
const config = loadPlugin(pluginPath);
|
|
@@ -26407,13 +26447,15 @@ async function installPlugin(pluginName, projectDir, options) {
|
|
|
26407
26447
|
pluginName
|
|
26408
26448
|
};
|
|
26409
26449
|
}
|
|
26410
|
-
var import_fs_extra3, copySync2, readFileSync4, writeFileSync2, existsSync4;
|
|
26450
|
+
var import_fs_extra3, copySync2, readFileSync4, writeFileSync2, existsSync4, __filename2, __dirname2;
|
|
26411
26451
|
var init_plugin_installer = __esm(() => {
|
|
26412
26452
|
init_plugin_loader();
|
|
26413
26453
|
init_index_min();
|
|
26414
26454
|
init_plugin_registry();
|
|
26415
26455
|
import_fs_extra3 = __toESM(require_lib(), 1);
|
|
26416
26456
|
({ copySync: copySync2, readFileSync: readFileSync4, writeFileSync: writeFileSync2, existsSync: existsSync4 } = import_fs_extra3.default);
|
|
26457
|
+
__filename2 = fileURLToPath(import.meta.url);
|
|
26458
|
+
__dirname2 = dirname(__filename2);
|
|
26417
26459
|
});
|
|
26418
26460
|
|
|
26419
26461
|
// packages/core/src/engine/template-loader.ts
|
|
@@ -26464,7 +26506,7 @@ function composeTemplate(templatePath, targetDir, options) {
|
|
|
26464
26506
|
});
|
|
26465
26507
|
}
|
|
26466
26508
|
// packages/core/src/engine/scaffolder.ts
|
|
26467
|
-
import { join as join6, dirname } from "path";
|
|
26509
|
+
import { join as join6, dirname as dirname2 } from "path";
|
|
26468
26510
|
import { existsSync as existsSync5, mkdirSync } from "fs";
|
|
26469
26511
|
init_lib();
|
|
26470
26512
|
|
|
@@ -26483,16 +26525,16 @@ class DevForgeError extends Error {
|
|
|
26483
26525
|
}
|
|
26484
26526
|
|
|
26485
26527
|
// packages/core/src/engine/scaffolder.ts
|
|
26486
|
-
import { fileURLToPath } from "url";
|
|
26487
|
-
var
|
|
26488
|
-
var
|
|
26528
|
+
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
26529
|
+
var __filename3 = fileURLToPath2(import.meta.url);
|
|
26530
|
+
var __dirname3 = dirname2(__filename3);
|
|
26489
26531
|
function getTemplatesRoot() {
|
|
26490
26532
|
const searchPaths = [
|
|
26491
|
-
join6(
|
|
26492
|
-
join6(
|
|
26493
|
-
join6(
|
|
26494
|
-
join6(
|
|
26495
|
-
join6(
|
|
26533
|
+
join6(__dirname3, "templates"),
|
|
26534
|
+
join6(__dirname3, "..", "templates"),
|
|
26535
|
+
join6(__dirname3, "..", "..", "templates"),
|
|
26536
|
+
join6(__dirname3, "..", "..", "..", "templates"),
|
|
26537
|
+
join6(__dirname3, "..", "..", "..", "..", "templates"),
|
|
26496
26538
|
join6(process.cwd(), "node_modules", "@ahmadubaidillah", "core", "templates"),
|
|
26497
26539
|
join6(process.cwd(), "templates")
|
|
26498
26540
|
];
|
|
@@ -26503,14 +26545,14 @@ function getTemplatesRoot() {
|
|
|
26503
26545
|
return p;
|
|
26504
26546
|
}
|
|
26505
26547
|
}
|
|
26506
|
-
let current =
|
|
26548
|
+
let current = __dirname3;
|
|
26507
26549
|
const root = "/";
|
|
26508
26550
|
while (current !== root) {
|
|
26509
26551
|
const p = join6(current, "templates");
|
|
26510
26552
|
if (existsSync5(p) && existsSync5(join6(p, "saas", "template.config.json"))) {
|
|
26511
26553
|
return p;
|
|
26512
26554
|
}
|
|
26513
|
-
const parent =
|
|
26555
|
+
const parent = dirname2(current);
|
|
26514
26556
|
if (parent === current)
|
|
26515
26557
|
break;
|
|
26516
26558
|
current = parent;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
|
|
3
|
+
|
|
4
|
+
const data = [
|
|
5
|
+
{ name: 'Mon', tenants: 40, users: 240 },
|
|
6
|
+
{ name: 'Tue', tenants: 45, users: 310 },
|
|
7
|
+
{ name: 'Wed', tenants: 50, users: 400 },
|
|
8
|
+
{ name: 'Thu', tenants: 52, users: 430 },
|
|
9
|
+
{ name: 'Fri', tenants: 60, users: 500 },
|
|
10
|
+
{ name: 'Sat', tenants: 65, users: 520 },
|
|
11
|
+
{ name: 'Sun', tenants: 75, users: 600 },
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
export function OverviewChart() {
|
|
15
|
+
return (
|
|
16
|
+
<ResponsiveContainer width="100%" height="100%">
|
|
17
|
+
<LineChart
|
|
18
|
+
data={data}
|
|
19
|
+
margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
|
|
20
|
+
>
|
|
21
|
+
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#E5E7EB" />
|
|
22
|
+
<XAxis
|
|
23
|
+
dataKey="name"
|
|
24
|
+
axisLine={false}
|
|
25
|
+
tickLine={false}
|
|
26
|
+
tick={{ fill: '#6B7280', fontSize: 12 }}
|
|
27
|
+
dy={10}
|
|
28
|
+
/>
|
|
29
|
+
<YAxis
|
|
30
|
+
axisLine={false}
|
|
31
|
+
tickLine={false}
|
|
32
|
+
tick={{ fill: '#6B7280', fontSize: 12 }}
|
|
33
|
+
dx={-10}
|
|
34
|
+
/>
|
|
35
|
+
<Tooltip
|
|
36
|
+
contentStyle={{ borderRadius: '8px', border: 'none', boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)' }}
|
|
37
|
+
/>
|
|
38
|
+
<Line
|
|
39
|
+
type="monotone"
|
|
40
|
+
dataKey="users"
|
|
41
|
+
stroke="#4F46E5"
|
|
42
|
+
strokeWidth={3}
|
|
43
|
+
dot={{ r: 4, fill: '#4F46E5', strokeWidth: 2, stroke: '#fff' }}
|
|
44
|
+
activeDot={{ r: 6 }}
|
|
45
|
+
/>
|
|
46
|
+
<Line
|
|
47
|
+
type="monotone"
|
|
48
|
+
dataKey="tenants"
|
|
49
|
+
stroke="#10B981"
|
|
50
|
+
strokeWidth={3}
|
|
51
|
+
dot={{ r: 4, fill: '#10B981', strokeWidth: 2, stroke: '#fff' }}
|
|
52
|
+
/>
|
|
53
|
+
</LineChart>
|
|
54
|
+
</ResponsiveContainer>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { EventBus } from '../../../../../../../core/src/hooks/event-bus';
|
|
2
|
+
|
|
3
|
+
export class AdminMetricsService {
|
|
4
|
+
constructor(private eventBus: EventBus) {}
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Broadcasts the current metrics to all listeners via the EventBus
|
|
8
|
+
*/
|
|
9
|
+
async broadcastMetrics(db: any) {
|
|
10
|
+
try {
|
|
11
|
+
// These queries would hit the actual Drizzle instances
|
|
12
|
+
// For scaffolding demonstration, using mock values if DB not provided
|
|
13
|
+
|
|
14
|
+
let activeTenants = 68;
|
|
15
|
+
let totalUsers = 420;
|
|
16
|
+
|
|
17
|
+
if (db) {
|
|
18
|
+
// activeTenants = await db.select({ count: sql`count(*)` }).from(organization);
|
|
19
|
+
// totalUsers = await db.select({ count: sql`count(*)` }).from(user);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const payload = {
|
|
23
|
+
type: 'PLATFORM_METRICS_UPDATE',
|
|
24
|
+
payload: {
|
|
25
|
+
activeTenants,
|
|
26
|
+
totalUsers,
|
|
27
|
+
sysHealth: 'optimal'
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
// This will be caught by the WebSocketService from Sprint 7
|
|
32
|
+
this.eventBus.dispatch('notification.broadcast', payload);
|
|
33
|
+
|
|
34
|
+
return payload.payload;
|
|
35
|
+
} catch (error: any) {
|
|
36
|
+
console.error(`[AdminMetricsService] Failed to broadcast metrics: ${error.message}`);
|
|
37
|
+
throw error;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import React, { useEffect, useState } from 'react';
|
|
2
|
+
import { Users, Activity, Settings, Database, Server } from 'lucide-react';
|
|
3
|
+
import { OverviewChart } from '../components/OverviewChart';
|
|
4
|
+
|
|
5
|
+
export function AdminDashboard() {
|
|
6
|
+
const [stats, setStats] = useState({
|
|
7
|
+
activeTenants: 0,
|
|
8
|
+
totalUsers: 0,
|
|
9
|
+
sysHealth: 'checking...',
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
useEffect(() => {
|
|
13
|
+
// 1. We connect to our WebSocket Plugin (Sprint 7)
|
|
14
|
+
const ws = new WebSocket(`ws://${window.location.host}/ws/admin`);
|
|
15
|
+
|
|
16
|
+
ws.onmessage = (event) => {
|
|
17
|
+
const data = JSON.parse(event.data);
|
|
18
|
+
if (data.type === 'PLATFORM_METRICS_UPDATE') {
|
|
19
|
+
setStats(data.payload);
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
return () => ws.close();
|
|
24
|
+
}, []);
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<div className="min-h-screen bg-gray-50 flex">
|
|
28
|
+
{/* Sidebar Navigation */}
|
|
29
|
+
<nav className="w-64 bg-white border-r border-gray-200 p-6 flex flex-col space-y-4">
|
|
30
|
+
<div className="text-xl font-bold tracking-tight text-gray-900 mb-8">
|
|
31
|
+
Platform Admin
|
|
32
|
+
</div>
|
|
33
|
+
<a href="#overview" className="flex items-center space-x-3 text-blue-600 bg-blue-50 px-3 py-2 rounded-lg font-medium">
|
|
34
|
+
<Activity className="w-5 h-5" />
|
|
35
|
+
<span>Overview</span>
|
|
36
|
+
</a>
|
|
37
|
+
<a href="#tenants" className="flex items-center space-x-3 text-gray-600 hover:bg-gray-50 px-3 py-2 rounded-lg font-medium transition-colors">
|
|
38
|
+
<Database className="w-5 h-5" />
|
|
39
|
+
<span>Tenants / Orgs</span>
|
|
40
|
+
</a>
|
|
41
|
+
<a href="#users" className="flex items-center space-x-3 text-gray-600 hover:bg-gray-50 px-3 py-2 rounded-lg font-medium transition-colors">
|
|
42
|
+
<Users className="w-5 h-5" />
|
|
43
|
+
<span>Global Users</span>
|
|
44
|
+
</a>
|
|
45
|
+
<a href="#system" className="flex items-center space-x-3 text-gray-600 hover:bg-gray-50 px-3 py-2 rounded-lg font-medium transition-colors">
|
|
46
|
+
<Server className="w-5 h-5" />
|
|
47
|
+
<span>System Health</span>
|
|
48
|
+
</a>
|
|
49
|
+
<div className="flex-grow" />
|
|
50
|
+
<a href="#settings" className="flex items-center space-x-3 text-gray-500 hover:text-gray-900 px-3 py-2 rounded-lg font-medium transition-colors">
|
|
51
|
+
<Settings className="w-5 h-5" />
|
|
52
|
+
<span>Settings</span>
|
|
53
|
+
</a>
|
|
54
|
+
</nav>
|
|
55
|
+
|
|
56
|
+
{/* Main Content Area */}
|
|
57
|
+
<main className="flex-1 p-8">
|
|
58
|
+
<header className="mb-8">
|
|
59
|
+
<h1 className="text-3xl font-bold text-gray-900">Dashboard</h1>
|
|
60
|
+
<p className="text-gray-500 mt-1">Real-time platform overview and metrics.</p>
|
|
61
|
+
</header>
|
|
62
|
+
|
|
63
|
+
{/* Top Stat Cards */}
|
|
64
|
+
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
|
65
|
+
<div className="bg-white p-6 rounded-xl border border-gray-200 shadow-sm flex items-center shadow-sm">
|
|
66
|
+
<div className="p-3 bg-blue-100 rounded-lg text-blue-600 mr-4">
|
|
67
|
+
<Database className="w-6 h-6" />
|
|
68
|
+
</div>
|
|
69
|
+
<div>
|
|
70
|
+
<p className="text-sm font-medium text-gray-500">Active Tenants</p>
|
|
71
|
+
<h3 className="text-2xl font-bold text-gray-900">{stats.activeTenants}</h3>
|
|
72
|
+
</div>
|
|
73
|
+
</div>
|
|
74
|
+
|
|
75
|
+
<div className="bg-white p-6 rounded-xl border border-gray-200 shadow-sm flex items-center shadow-sm">
|
|
76
|
+
<div className="p-3 bg-indigo-100 rounded-lg text-indigo-600 mr-4">
|
|
77
|
+
<Users className="w-6 h-6" />
|
|
78
|
+
</div>
|
|
79
|
+
<div>
|
|
80
|
+
<p className="text-sm font-medium text-gray-500">Total Global Users</p>
|
|
81
|
+
<h3 className="text-2xl font-bold text-gray-900">{stats.totalUsers}</h3>
|
|
82
|
+
</div>
|
|
83
|
+
</div>
|
|
84
|
+
|
|
85
|
+
<div className="bg-white p-6 rounded-xl border border-gray-200 shadow-sm flex items-center shadow-sm">
|
|
86
|
+
<div className="p-3 bg-emerald-100 rounded-lg text-emerald-600 mr-4">
|
|
87
|
+
<Activity className="w-6 h-6" />
|
|
88
|
+
</div>
|
|
89
|
+
<div>
|
|
90
|
+
<p className="text-sm font-medium text-gray-500">System Status</p>
|
|
91
|
+
<h3 className="text-xl font-bold text-gray-900 capitalize">{stats.sysHealth}</h3>
|
|
92
|
+
</div>
|
|
93
|
+
</div>
|
|
94
|
+
</div>
|
|
95
|
+
|
|
96
|
+
{/* Charts & Tables Area */}
|
|
97
|
+
<div className="bg-white border border-gray-200 rounded-xl shadow-sm p-6">
|
|
98
|
+
<h2 className="text-lg font-semibold text-gray-900 mb-6">Activity Timeline</h2>
|
|
99
|
+
<div className="h-72 w-full">
|
|
100
|
+
<OverviewChart />
|
|
101
|
+
</div>
|
|
102
|
+
</div>
|
|
103
|
+
</main>
|
|
104
|
+
</div>
|
|
105
|
+
);
|
|
106
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "admin-panel",
|
|
3
|
+
"description": "God Mode Dashboard and Super-Admin Management Interface.",
|
|
4
|
+
"compatibleTemplates": ["saas", "marketplace"],
|
|
5
|
+
"packageDependencies": {
|
|
6
|
+
"lucide-react": "^0.300.0",
|
|
7
|
+
"recharts": "^2.10.0"
|
|
8
|
+
},
|
|
9
|
+
"packageDevDependencies": {}
|
|
10
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { PostHog } from 'posthog-node';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* AnalyticsService
|
|
5
|
+
*
|
|
6
|
+
* Provides a unified way to track user events across the application.
|
|
7
|
+
* Defaults to PostHog but follows a pattern that can be extended.
|
|
8
|
+
*/
|
|
9
|
+
export class AnalyticsService {
|
|
10
|
+
private client: PostHog | null = null;
|
|
11
|
+
|
|
12
|
+
constructor(apiKey?: string, host?: string) {
|
|
13
|
+
if (apiKey) {
|
|
14
|
+
this.client = new PostHog(apiKey, {
|
|
15
|
+
host: host || 'https://app.posthog.com',
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Capture a custom event.
|
|
22
|
+
*/
|
|
23
|
+
async capture(distinctId: string, event: string, properties?: Record<string, any>) {
|
|
24
|
+
try {
|
|
25
|
+
if (!this.client) {
|
|
26
|
+
console.log(`[Analytics Mock] Event: ${event} for ${distinctId}`, properties);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
this.client.capture({
|
|
31
|
+
distinctId,
|
|
32
|
+
event,
|
|
33
|
+
properties,
|
|
34
|
+
});
|
|
35
|
+
} catch (error: any) {
|
|
36
|
+
console.error(`[AnalyticsService] Capture failed: ${error.message}`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Identify a user with custom traits.
|
|
42
|
+
*/
|
|
43
|
+
async identify(distinctId: string, properties: Record<string, any>) {
|
|
44
|
+
try {
|
|
45
|
+
if (!this.client) {
|
|
46
|
+
console.log(`[Analytics Mock] Identify: ${distinctId}`, properties);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
this.client.identify({
|
|
51
|
+
distinctId,
|
|
52
|
+
properties,
|
|
53
|
+
});
|
|
54
|
+
} catch (error: any) {
|
|
55
|
+
console.error(`[AnalyticsService] Identify failed: ${error.message}`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Flush pending events (useful for serverless environments).
|
|
61
|
+
*/
|
|
62
|
+
async flush() {
|
|
63
|
+
try {
|
|
64
|
+
if (this.client) {
|
|
65
|
+
await this.client.flush();
|
|
66
|
+
}
|
|
67
|
+
} catch (error: any) {
|
|
68
|
+
console.error(`[AnalyticsService] Flush failed: ${error.message}`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
export const signupSchema = z.object({
|
|
4
|
+
email: z.string().email(),
|
|
5
|
+
password: z.string().min(8),
|
|
6
|
+
name: z.string().optional(),
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
export const loginSchema = z.object({
|
|
10
|
+
email: z.string().email(),
|
|
11
|
+
password: z.string(),
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
export const organizationSchema = z.object({
|
|
15
|
+
name: z.string().min(1),
|
|
16
|
+
slug: z.string().min(1),
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
export type SignupInput = z.infer<typeof signupSchema>;
|
|
20
|
+
export type LoginInput = z.infer<typeof loginSchema>;
|
|
21
|
+
export type OrganizationInput = z.infer<typeof organizationSchema>;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { sql } from 'drizzle-orm';
|
|
2
|
+
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* setTenantSession
|
|
6
|
+
*
|
|
7
|
+
* Sets the current organization ID in the Postgres session.
|
|
8
|
+
* Used for Row Level Security (RLS).
|
|
9
|
+
*/
|
|
10
|
+
export async function setTenantSession(db: NodePgDatabase<any>, organizationId: string) {
|
|
11
|
+
await db.execute(sql`SET LOCAL app.current_organization_id = ${organizationId}`);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* clearTenantSession
|
|
16
|
+
*
|
|
17
|
+
* Clears the organization ID from the session.
|
|
18
|
+
*/
|
|
19
|
+
export async function clearTenantSession(db: NodePgDatabase<any>) {
|
|
20
|
+
await db.execute(sql`SET LOCAL app.current_organization_id = ''`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* rlsPolicy
|
|
25
|
+
*
|
|
26
|
+
* Returns the SQL expression for the RLS policy.
|
|
27
|
+
* Usage in Postgres:
|
|
28
|
+
* CREATE POLICY tenant_isolation ON table_name
|
|
29
|
+
* USING (organization_id = current_setting('app.current_organization_id', true));
|
|
30
|
+
*/
|
|
31
|
+
export const rlsPolicySql = sql`organization_id = current_setting('app.current_organization_id', true)`;
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { pgTable, text, timestamp, boolean, index } from "drizzle-orm/pg-core";
|
|
2
|
+
|
|
3
|
+
export const user = pgTable("user", {
|
|
4
|
+
id: text("id").primaryKey(),
|
|
5
|
+
name: text("name").notNull(),
|
|
6
|
+
email: text("email").notNull().unique(),
|
|
7
|
+
emailVerified: boolean("email_verified").default(false).notNull(),
|
|
8
|
+
image: text("image"),
|
|
9
|
+
createdAt: timestamp("created_at").defaultNow().notNull(),
|
|
10
|
+
updatedAt: timestamp("updated_at")
|
|
11
|
+
.defaultNow()
|
|
12
|
+
.$onUpdate(() => new Date())
|
|
13
|
+
.notNull(),
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
export const session = pgTable("session", {
|
|
17
|
+
id: text("id").primaryKey(),
|
|
18
|
+
expiresAt: timestamp("expires_at").notNull(),
|
|
19
|
+
token: text("token").notNull().unique(),
|
|
20
|
+
createdAt: timestamp("created_at").defaultNow().notNull(),
|
|
21
|
+
updatedAt: timestamp("updated_at")
|
|
22
|
+
.defaultNow()
|
|
23
|
+
.$onUpdate(() => new Date())
|
|
24
|
+
.notNull(),
|
|
25
|
+
ipAddress: text("ip_address"),
|
|
26
|
+
userAgent: text("user_agent"),
|
|
27
|
+
userId: text("user_id")
|
|
28
|
+
.notNull()
|
|
29
|
+
.references(() => user.id, { onDelete: "cascade" }),
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
export const account = pgTable("account", {
|
|
33
|
+
id: text("id").primaryKey(),
|
|
34
|
+
accountId: text("account_id").notNull(),
|
|
35
|
+
providerId: text("provider_id").notNull(),
|
|
36
|
+
userId: text("user_id")
|
|
37
|
+
.notNull()
|
|
38
|
+
.references(() => user.id, { onDelete: "cascade" }),
|
|
39
|
+
accessToken: text("access_token"),
|
|
40
|
+
refreshToken: text("refresh_token"),
|
|
41
|
+
idToken: text("id_token"),
|
|
42
|
+
accessTokenExpiresAt: timestamp("access_token_expires_at"),
|
|
43
|
+
refreshTokenExpiresAt: timestamp("refresh_token_expires_at"),
|
|
44
|
+
scope: text("scope"),
|
|
45
|
+
password: text("password"),
|
|
46
|
+
createdAt: timestamp("created_at").defaultNow().notNull(),
|
|
47
|
+
updatedAt: timestamp("updated_at")
|
|
48
|
+
.defaultNow()
|
|
49
|
+
.$onUpdate(() => new Date())
|
|
50
|
+
.notNull(),
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
export const verification = pgTable("verification", {
|
|
54
|
+
id: text("id").primaryKey(),
|
|
55
|
+
identifier: text("identifier").notNull(),
|
|
56
|
+
value: text("value").notNull(),
|
|
57
|
+
expiresAt: timestamp("expires_at").notNull(),
|
|
58
|
+
createdAt: timestamp("created_at").defaultNow().notNull(),
|
|
59
|
+
updatedAt: timestamp("updated_at")
|
|
60
|
+
.defaultNow()
|
|
61
|
+
.$onUpdate(() => new Date())
|
|
62
|
+
.notNull(),
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Organization Plugin Tables
|
|
67
|
+
*/
|
|
68
|
+
export const organization = pgTable("organization", {
|
|
69
|
+
id: text("id").primaryKey(),
|
|
70
|
+
name: text("name").notNull(),
|
|
71
|
+
slug: text("slug").notNull().unique(),
|
|
72
|
+
logo: text("logo"),
|
|
73
|
+
createdAt: timestamp("created_at").defaultNow().notNull(),
|
|
74
|
+
metadata: text("metadata"),
|
|
75
|
+
}, (table) => [
|
|
76
|
+
index("organization_slug_idx").on(table.slug),
|
|
77
|
+
]);
|
|
78
|
+
|
|
79
|
+
export const member = pgTable("member", {
|
|
80
|
+
id: text("id").primaryKey(),
|
|
81
|
+
organizationId: text("organization_id")
|
|
82
|
+
.notNull()
|
|
83
|
+
.references(() => organization.id, { onDelete: "cascade" }),
|
|
84
|
+
userId: text("user_id")
|
|
85
|
+
.notNull()
|
|
86
|
+
.references(() => user.id, { onDelete: "cascade" }),
|
|
87
|
+
role: text("role").notNull(),
|
|
88
|
+
createdAt: timestamp("created_at").defaultNow().notNull(),
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
export const invitation = pgTable("invitation", {
|
|
92
|
+
id: text("id").primaryKey(),
|
|
93
|
+
organizationId: text("organization_id")
|
|
94
|
+
.notNull()
|
|
95
|
+
.references(() => organization.id, { onDelete: "cascade" }),
|
|
96
|
+
email: text("email").notNull(),
|
|
97
|
+
role: text("role").notNull(),
|
|
98
|
+
status: text("status").notNull(), // pending, accepted, rejected
|
|
99
|
+
expiresAt: timestamp("expires_at").notNull(),
|
|
100
|
+
inviterId: text("inviter_id")
|
|
101
|
+
.notNull()
|
|
102
|
+
.references(() => user.id, { onDelete: "cascade" }),
|
|
103
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { createMiddleware } from 'hono/factory';
|
|
2
|
+
|
|
3
|
+
export const authMiddleware = createMiddleware(async (c, next) => {
|
|
4
|
+
const token = c.req.header('Authorization');
|
|
5
|
+
|
|
6
|
+
if (!token) {
|
|
7
|
+
return c.json({ error: 'Unauthorized' }, 401);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// Token verification logic
|
|
11
|
+
await next();
|
|
12
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { createMiddleware } from 'hono/factory';
|
|
2
|
+
import { HTTPException } from 'hono/http-exception';
|
|
3
|
+
import { AuthService } from '../services/auth.service';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* tenantMiddleware
|
|
7
|
+
*
|
|
8
|
+
* Extracts the organization slug from the URL path (/ :tenant /...)
|
|
9
|
+
* and validates its existence. It also checks if the user has access
|
|
10
|
+
* to this organization if a session exists.
|
|
11
|
+
*/
|
|
12
|
+
export const tenantMiddleware = (authService: AuthService) => createMiddleware(async (c, next) => {
|
|
13
|
+
const tenantSlug = c.req.param('tenant');
|
|
14
|
+
|
|
15
|
+
if (!tenantSlug) {
|
|
16
|
+
return await next();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Look up organization by slug
|
|
20
|
+
// In a real implementation, we'd use a repository or the better-auth api
|
|
21
|
+
const org = await authService.auth.api.getOrganizationBySlug({
|
|
22
|
+
query: { slug: tenantSlug }
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
if (!org) {
|
|
26
|
+
throw new HTTPException(404, { message: 'Organization not found' });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Set organization in context
|
|
30
|
+
c.set('organization', org);
|
|
31
|
+
c.set('organizationId', org.id);
|
|
32
|
+
|
|
33
|
+
// Check user membership if authenticated
|
|
34
|
+
const session = await authService.getSession(c.req.raw);
|
|
35
|
+
if (session) {
|
|
36
|
+
const member = await authService.auth.api.getMember({
|
|
37
|
+
query: {
|
|
38
|
+
organizationId: org.id,
|
|
39
|
+
userId: session.user.id
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
if (!member) {
|
|
44
|
+
throw new HTTPException(403, { message: 'You are not a member of this organization' });
|
|
45
|
+
}
|
|
46
|
+
c.set('member', member);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
await next();
|
|
50
|
+
});
|