commit
This commit is contained in:
96
scripts/check-architecture.js
Normal file
96
scripts/check-architecture.js
Normal file
@@ -0,0 +1,96 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const ROOT = process.cwd();
|
||||
const dashboardRoot = path.join(ROOT, 'src', 'app', 'dashboard');
|
||||
const requiredGuardPattern =
|
||||
/require(Employee|HRD|IT|UserManagement|SuperAdmin|Permission)DashboardAccess/;
|
||||
const allowedWithoutGuard = new Set([
|
||||
'src/app/dashboard/layout.tsx',
|
||||
'src/app/dashboard/page.tsx',
|
||||
'src/app/dashboard/overview/layout.tsx',
|
||||
'src/app/dashboard/organizers/page.tsx'
|
||||
]);
|
||||
const legacyRoutePrefixes = [
|
||||
'src/app/dashboard/billing/',
|
||||
'src/app/dashboard/chat/',
|
||||
'src/app/dashboard/elements/',
|
||||
'src/app/dashboard/exclusive/',
|
||||
'src/app/dashboard/forms/',
|
||||
'src/app/dashboard/kanban/',
|
||||
'src/app/dashboard/profile/',
|
||||
'src/app/dashboard/product/',
|
||||
'src/app/dashboard/react-query/',
|
||||
'src/app/dashboard/workspaces/'
|
||||
];
|
||||
const guardedByLayoutPrefixes = ['src/app/dashboard/overview/'];
|
||||
|
||||
function walk(dir, results = []) {
|
||||
if (!fs.existsSync(dir)) {
|
||||
return results;
|
||||
}
|
||||
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
walk(fullPath, results);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.name === 'page.tsx' || entry.name === 'layout.tsx') {
|
||||
results.push(fullPath);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
function isLegacy(relativePath) {
|
||||
return legacyRoutePrefixes.some((prefix) => relativePath.startsWith(prefix));
|
||||
}
|
||||
|
||||
function isGuardedByParentLayout(relativePath) {
|
||||
return guardedByLayoutPrefixes.some(
|
||||
(prefix) => relativePath.startsWith(prefix) && relativePath !== `${prefix}layout.tsx`
|
||||
);
|
||||
}
|
||||
|
||||
function main() {
|
||||
const files = walk(dashboardRoot);
|
||||
const missingGuards = [];
|
||||
|
||||
for (const filePath of files) {
|
||||
const relativePath = path.relative(ROOT, filePath).replace(/\\/g, '/');
|
||||
|
||||
if (
|
||||
allowedWithoutGuard.has(relativePath) ||
|
||||
isLegacy(relativePath) ||
|
||||
isGuardedByParentLayout(relativePath)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(filePath, 'utf8');
|
||||
|
||||
if (!requiredGuardPattern.test(content)) {
|
||||
missingGuards.push(relativePath);
|
||||
}
|
||||
}
|
||||
|
||||
if (missingGuards.length === 0) {
|
||||
console.log('check-architecture: passed');
|
||||
return;
|
||||
}
|
||||
|
||||
console.error('check-architecture: dashboard entrypoints missing shared page guards');
|
||||
for (const relativePath of missingGuards) {
|
||||
console.error(`- ${relativePath}`);
|
||||
}
|
||||
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
||||
main();
|
||||
141
scripts/check-routes.js
Normal file
141
scripts/check-routes.js
Normal file
@@ -0,0 +1,141 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const ROOT = process.cwd();
|
||||
const dashboardRoot = path.join(ROOT, 'src', 'app', 'dashboard');
|
||||
const scanRoots = [
|
||||
path.join(ROOT, 'src', 'app'),
|
||||
path.join(ROOT, 'src', 'features'),
|
||||
path.join(ROOT, 'src', 'config')
|
||||
];
|
||||
const legacyNavRoutes = [
|
||||
'/dashboard/billing',
|
||||
'/dashboard/chat',
|
||||
'/dashboard/elements/icons',
|
||||
'/dashboard/exclusive',
|
||||
'/dashboard/forms',
|
||||
'/dashboard/kanban',
|
||||
'/dashboard/product',
|
||||
'/dashboard/react-query',
|
||||
'/dashboard/workspaces'
|
||||
];
|
||||
|
||||
function walk(dir, predicate = () => true, results = []) {
|
||||
if (!fs.existsSync(dir)) {
|
||||
return results;
|
||||
}
|
||||
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
walk(fullPath, predicate, results);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (predicate(fullPath)) {
|
||||
results.push(fullPath);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
function toRoutePattern(filePath) {
|
||||
const relative = path.relative(dashboardRoot, filePath).replace(/\\/g, '/');
|
||||
const withoutPage = relative.replace(/\/page\.tsx$/, '').replace(/^page\.tsx$/, '');
|
||||
const routePath = withoutPage ? `/dashboard/${withoutPage}` : '/dashboard';
|
||||
|
||||
return routePath.split('/').filter(Boolean);
|
||||
}
|
||||
|
||||
function matchesRoute(routePattern, routePath) {
|
||||
const targetSegments = routePath.split('/').filter(Boolean);
|
||||
|
||||
if (routePattern.length !== targetSegments.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return routePattern.every((segment, index) => {
|
||||
if (segment.startsWith('[') && segment.endsWith(']')) {
|
||||
return targetSegments[index].length > 0;
|
||||
}
|
||||
|
||||
return segment === targetSegments[index];
|
||||
});
|
||||
}
|
||||
|
||||
function collectRoutePatterns() {
|
||||
return walk(dashboardRoot, (filePath) => filePath.endsWith(`${path.sep}page.tsx`)).map(
|
||||
toRoutePattern
|
||||
);
|
||||
}
|
||||
|
||||
function collectDashboardReferences() {
|
||||
const routeRegex = /\/dashboard(?:\/[A-Za-z0-9-_[\]/]+)?/g;
|
||||
const references = new Map();
|
||||
|
||||
for (const root of scanRoots) {
|
||||
const files = walk(root, (filePath) => /\.(ts|tsx|js|jsx|md)$/.test(filePath));
|
||||
|
||||
for (const filePath of files) {
|
||||
const content = fs.readFileSync(filePath, 'utf8');
|
||||
const matches = content.match(routeRegex) ?? [];
|
||||
|
||||
for (const match of matches) {
|
||||
const normalized = match.replace(/\/+$/, '');
|
||||
if (!references.has(normalized)) {
|
||||
references.set(normalized, []);
|
||||
}
|
||||
|
||||
references.get(normalized).push(path.relative(ROOT, filePath).replace(/\\/g, '/'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return references;
|
||||
}
|
||||
|
||||
function main() {
|
||||
const routePatterns = collectRoutePatterns();
|
||||
const references = collectDashboardReferences();
|
||||
const missingRoutes = [];
|
||||
|
||||
for (const [routePath, files] of references.entries()) {
|
||||
const exists = routePatterns.some((pattern) => matchesRoute(pattern, routePath));
|
||||
|
||||
if (!exists) {
|
||||
missingRoutes.push({ routePath, files });
|
||||
}
|
||||
}
|
||||
|
||||
const navConfigPath = path.join(ROOT, 'src', 'config', 'nav-config.ts');
|
||||
const navConfig = fs.existsSync(navConfigPath) ? fs.readFileSync(navConfigPath, 'utf8') : '';
|
||||
const leakedLegacyRoutes = legacyNavRoutes.filter((routePath) => navConfig.includes(routePath));
|
||||
|
||||
if (missingRoutes.length === 0 && leakedLegacyRoutes.length === 0) {
|
||||
console.log('check-routes: passed');
|
||||
return;
|
||||
}
|
||||
|
||||
if (missingRoutes.length > 0) {
|
||||
console.error('check-routes: missing dashboard route targets detected');
|
||||
for (const { routePath, files } of missingRoutes) {
|
||||
console.error(`- ${routePath}`);
|
||||
console.error(` referenced from: ${files.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (leakedLegacyRoutes.length > 0) {
|
||||
console.error('check-routes: legacy/demo routes are still present in nav-config');
|
||||
for (const routePath of leakedLegacyRoutes) {
|
||||
console.error(`- ${routePath}`);
|
||||
}
|
||||
}
|
||||
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
||||
main();
|
||||
1146
scripts/cleanup.js
Normal file
1146
scripts/cleanup.js
Normal file
File diff suppressed because it is too large
Load Diff
78
scripts/postinstall.js
Normal file
78
scripts/postinstall.js
Normal file
@@ -0,0 +1,78 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const cleanupPath = path.join(__dirname, 'cleanup.js');
|
||||
|
||||
// If cleanup.js is gone, self-clean: remove this file and restore dev script
|
||||
if (!fs.existsSync(cleanupPath)) {
|
||||
try {
|
||||
const pkgPath = path.join(__dirname, '..', 'package.json');
|
||||
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
||||
if (pkg.scripts?.dev?.includes('postinstall.js')) {
|
||||
pkg.scripts.dev = 'next dev';
|
||||
fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n', 'utf8');
|
||||
}
|
||||
fs.unlinkSync(__filename);
|
||||
} catch (_) {}
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// ── ANSI colors ─────────────────────────────────────────────────────
|
||||
|
||||
const c = {
|
||||
r: '\x1b[0m', // reset
|
||||
d: '\x1b[2m', // dim
|
||||
b: '\x1b[1m', // bold
|
||||
cyan: '\x1b[36m',
|
||||
mag: '\x1b[35m',
|
||||
yel: '\x1b[33m',
|
||||
grn: '\x1b[32m',
|
||||
wht: '\x1b[37m',
|
||||
bgCyan: '\x1b[46m\x1b[30m'
|
||||
};
|
||||
|
||||
// ── Box drawing (ANSI-safe padding) ─────────────────────────────────
|
||||
|
||||
const W = 64;
|
||||
const strip = (s) => s.replace(/\x1b\[[0-9;]*m/g, '');
|
||||
const pad = (s, w) => s + ' '.repeat(Math.max(0, w - strip(s).length));
|
||||
const row = (text = '') => `${c.d}│${c.r} ${pad(text, W - 4)} ${c.d}│${c.r}`;
|
||||
const top = `${c.d}┌${'─'.repeat(W - 2)}┐${c.r}`;
|
||||
const bot = `${c.d}└${'─'.repeat(W - 2)}┘${c.r}`;
|
||||
const div = `${c.d}├${'─'.repeat(W - 2)}┤${c.r}`;
|
||||
|
||||
// ── Message ─────────────────────────────────────────────────────────
|
||||
|
||||
const msg = [
|
||||
'',
|
||||
top,
|
||||
row(),
|
||||
row(`${c.b}${c.mag} ✦ Shadcn Dashboard Starter${c.r}`),
|
||||
row(`${c.d} Feature Cleanup Available${c.r}`),
|
||||
row(),
|
||||
div,
|
||||
row(),
|
||||
row(`${c.wht}Trim optional features to fit your project:${c.r}`),
|
||||
row(),
|
||||
row(` ${c.yel}$${c.r} ${c.b}node scripts/cleanup.js --interactive${c.r}`),
|
||||
row(),
|
||||
row(`${c.d}Available modules:${c.r}`),
|
||||
row(
|
||||
` ${c.cyan}clerk${c.d} · ${c.cyan}kanban${c.d} · ${c.cyan}chat${c.d} · ${c.cyan}notifications${c.d} · ${c.cyan}themes${c.d} · ${c.cyan}sentry${c.r}`
|
||||
),
|
||||
row(),
|
||||
div,
|
||||
row(),
|
||||
row(`${c.grn}--dry-run${c.r} ${c.d}Preview changes without modifying files${c.r}`),
|
||||
row(`${c.grn}--list${c.r} ${c.d}Show all removable features${c.r}`),
|
||||
row(`${c.grn}--help${c.r} ${c.d}See all options${c.r}`),
|
||||
row(),
|
||||
row(`${c.d}Delete ${c.wht}scripts/cleanup.js${c.d} to remove this message.${c.r}`),
|
||||
row(),
|
||||
bot,
|
||||
''
|
||||
];
|
||||
|
||||
console.log(msg.join('\n'));
|
||||
119
scripts/reset-db.js
Normal file
119
scripts/reset-db.js
Normal file
@@ -0,0 +1,119 @@
|
||||
const crypto = require('node:crypto');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const postgres = require('postgres');
|
||||
|
||||
function readEnvFile(filePath) {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(filePath, 'utf8');
|
||||
return Object.fromEntries(
|
||||
content
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line && !line.startsWith('#') && line.includes('='))
|
||||
.map((line) => {
|
||||
const index = line.indexOf('=');
|
||||
const key = line.slice(0, index).trim();
|
||||
const value = line.slice(index + 1).trim();
|
||||
return [key, value];
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function resolveDatabaseUrl() {
|
||||
if (process.env.DATABASE_URL) {
|
||||
return process.env.DATABASE_URL;
|
||||
}
|
||||
|
||||
const rootDir = process.cwd();
|
||||
const envLocal = readEnvFile(path.join(rootDir, '.env.local'));
|
||||
if (envLocal.DATABASE_URL) {
|
||||
return envLocal.DATABASE_URL;
|
||||
}
|
||||
|
||||
const env = readEnvFile(path.join(rootDir, '.env'));
|
||||
if (env.DATABASE_URL) {
|
||||
return env.DATABASE_URL;
|
||||
}
|
||||
|
||||
throw new Error('DATABASE_URL was not found in process.env, .env.local, or .env');
|
||||
}
|
||||
|
||||
function getMigrationFiles(rootDir) {
|
||||
return fs
|
||||
.readdirSync(path.join(rootDir, 'drizzle'))
|
||||
.filter((file) => file.endsWith('.sql'))
|
||||
.sort((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const databaseUrl = resolveDatabaseUrl();
|
||||
const databaseName = new URL(databaseUrl).pathname.replace(/^\//, '');
|
||||
const rootDir = process.cwd();
|
||||
const migrationFiles = getMigrationFiles(rootDir);
|
||||
|
||||
if (!databaseName) {
|
||||
throw new Error('DATABASE_URL must include a database name');
|
||||
}
|
||||
|
||||
const sql = postgres(databaseUrl, {
|
||||
max: 1,
|
||||
prepare: false
|
||||
});
|
||||
|
||||
console.log(`Resetting database "${databaseName}"...`);
|
||||
try {
|
||||
await sql`
|
||||
select pg_terminate_backend(pid)
|
||||
from pg_stat_activity
|
||||
where datname = current_database()
|
||||
and pid <> pg_backend_pid()
|
||||
`;
|
||||
|
||||
await sql.unsafe('drop schema if exists drizzle cascade');
|
||||
await sql.unsafe('drop schema if exists public cascade');
|
||||
await sql.unsafe('create schema public');
|
||||
await sql.unsafe('grant all on schema public to public');
|
||||
await sql.unsafe('grant all on schema public to postgres');
|
||||
await sql.unsafe('create schema drizzle');
|
||||
await sql.unsafe(`
|
||||
create table drizzle.__drizzle_migrations (
|
||||
id serial primary key,
|
||||
hash text not null,
|
||||
created_at bigint not null
|
||||
)
|
||||
`);
|
||||
|
||||
for (const fileName of migrationFiles) {
|
||||
const filePath = path.join(rootDir, 'drizzle', fileName);
|
||||
const sqlContent = fs.readFileSync(filePath, 'utf8');
|
||||
const statements = sqlContent
|
||||
.split('--> statement-breakpoint')
|
||||
.map((statement) => statement.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
for (const statement of statements) {
|
||||
await sql.unsafe(statement);
|
||||
}
|
||||
|
||||
const hash = crypto.createHash('sha256').update(sqlContent).digest('hex');
|
||||
await sql`
|
||||
insert into drizzle.__drizzle_migrations (hash, created_at)
|
||||
values (${hash}, ${String(Date.now())})
|
||||
`;
|
||||
}
|
||||
} finally {
|
||||
await sql.end();
|
||||
}
|
||||
|
||||
console.log('Database reset completed.');
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('Failed to reset database.');
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
91
scripts/seed-master-data.js
Normal file
91
scripts/seed-master-data.js
Normal file
@@ -0,0 +1,91 @@
|
||||
const postgres = require("postgres");
|
||||
|
||||
const DEFAULT_DEPARTMENTS = [
|
||||
[
|
||||
"HR",
|
||||
"Human Resources",
|
||||
"People operations, recruitment, and employee relations.",
|
||||
],
|
||||
[
|
||||
"QA",
|
||||
"Quality Assurance",
|
||||
"Quality systems, audits, and compliance oversight.",
|
||||
],
|
||||
["PRD", "Production", "Manufacturing, operations, and production planning."],
|
||||
[
|
||||
"ENG",
|
||||
"Engineering",
|
||||
"Technical development, maintenance, and process improvement.",
|
||||
],
|
||||
[
|
||||
"LOG",
|
||||
"Logistics",
|
||||
"Warehouse, shipping, receiving, and inventory coordination.",
|
||||
],
|
||||
["FIN", "Finance", "Accounting, budgeting, and financial control."],
|
||||
];
|
||||
|
||||
const DEFAULT_POSITIONS = [
|
||||
["MGR", "Manager", "Leads team execution, planning, and performance."],
|
||||
["SUP", "Supervisor", "Oversees day-to-day operations and frontline staff."],
|
||||
[
|
||||
"SNR",
|
||||
"Senior Staff",
|
||||
"Experienced individual contributor with advanced responsibility.",
|
||||
],
|
||||
[
|
||||
"STF",
|
||||
"Staff",
|
||||
"Core contributor responsible for standard operational work.",
|
||||
],
|
||||
["JNR", "Junior Staff", "Entry-level contributor working under guidance."],
|
||||
["TRN", "Trainee", "Newly onboarded employee in structured training period."],
|
||||
];
|
||||
|
||||
async function main() {
|
||||
const connectionString =
|
||||
"postgres://postgres:admin1234@localhost:5432/training_system";
|
||||
|
||||
if (!connectionString) {
|
||||
throw new Error("DATABASE_URL is required");
|
||||
}
|
||||
|
||||
const sql = postgres(connectionString, { prepare: false });
|
||||
|
||||
try {
|
||||
const organizations = await sql`
|
||||
select id, name
|
||||
from organizations
|
||||
order by created_at asc
|
||||
`;
|
||||
|
||||
for (const organization of organizations) {
|
||||
for (const [code, name, description] of DEFAULT_DEPARTMENTS) {
|
||||
await sql`
|
||||
insert into departments (organization_id, code, name, description)
|
||||
values (${organization.id}, ${code}, ${name}, ${description})
|
||||
on conflict (organization_id, name) do nothing
|
||||
`;
|
||||
}
|
||||
|
||||
for (const [code, name, description] of DEFAULT_POSITIONS) {
|
||||
await sql`
|
||||
insert into positions (organization_id, code, name, description)
|
||||
values (${organization.id}, ${code}, ${name}, ${description})
|
||||
on conflict (organization_id, name) do nothing
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Seeded master data for ${organizations.length} organization(s).`,
|
||||
);
|
||||
} finally {
|
||||
await sql.end({ timeout: 1 });
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error("Failed to seed master data.", error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
82
scripts/seed-super-admin.js
Normal file
82
scripts/seed-super-admin.js
Normal file
@@ -0,0 +1,82 @@
|
||||
const postgres = require("postgres");
|
||||
const { hash } = require("bcryptjs");
|
||||
const crypto = require("node:crypto");
|
||||
|
||||
async function main() {
|
||||
const connectionString =
|
||||
"postgres://postgres:admin1234@localhost:5432/training_system";
|
||||
|
||||
if (!connectionString) {
|
||||
throw new Error("DATABASE_URL is required");
|
||||
}
|
||||
|
||||
const email = (
|
||||
process.env.SUPER_ADMIN_EMAIL || "superadmin@training-system.local"
|
||||
)
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const name = "Super Admin".trim();
|
||||
const password = "SuperAdmin123!".trim();
|
||||
|
||||
if (password.length < 8) {
|
||||
throw new Error("SUPER_ADMIN_PASSWORD must be at least 8 characters");
|
||||
}
|
||||
|
||||
const sql = postgres(connectionString, { prepare: false });
|
||||
|
||||
try {
|
||||
const passwordHash = await hash(password, 10);
|
||||
const existingUser = await sql`
|
||||
select id
|
||||
from users
|
||||
where email = ${email}
|
||||
limit 1
|
||||
`;
|
||||
|
||||
if (existingUser.length > 0) {
|
||||
await sql`
|
||||
update users
|
||||
set
|
||||
name = ${name},
|
||||
password_hash = ${passwordHash},
|
||||
system_role = 'super_admin',
|
||||
is_active = true,
|
||||
updated_at = now()
|
||||
where id = ${existingUser[0].id}
|
||||
`;
|
||||
|
||||
console.log(`Updated existing super admin: ${email}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const userId = crypto.randomUUID();
|
||||
|
||||
await sql`
|
||||
insert into users (
|
||||
id,
|
||||
name,
|
||||
email,
|
||||
password_hash,
|
||||
system_role,
|
||||
is_active
|
||||
)
|
||||
values (
|
||||
${userId},
|
||||
${name},
|
||||
${email},
|
||||
${passwordHash},
|
||||
'super_admin',
|
||||
true
|
||||
)
|
||||
`;
|
||||
|
||||
console.log(`Created super admin: ${email}`);
|
||||
} finally {
|
||||
await sql.end({ timeout: 1 });
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error("Failed to seed super admin.", error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
1406
scripts/seed-uat-data.js
Normal file
1406
scripts/seed-uat-data.js
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user