2f20b1e5c2
Build & Push Docker Image / build (push) Successful in 2m15s
- tools: add deletedAt/deletedBy, soft delete via DELETE /tools/:id when actor has trash entitlement, else immediate hard delete - trash endpoints: GET /tools/trash, POST /tools/trash (admin bulk), POST /tools/trash/restore, DELETE /tools/trash, POST /tools/trash/empty - trash feature for premium/enterprise; exclude trashed from all public surfaces (browse, categories, features, tags, similar, ratings, costs, analytics, redundancy) - TRASH_RETENTION_DAYS env (0 = keep forever) with hourly purge job - frontend: /trash page (premium+, restore for all, permanent delete + empty for admin), admin Tools tab with multi-select bulk trash, sidebar Trash link, tool-detail delete hint
229 lines
8.3 KiB
TypeScript
229 lines
8.3 KiB
TypeScript
import app from "./app";
|
|
import { logger } from "./lib/logger";
|
|
import bcrypt from "bcryptjs";
|
|
import { db, usersTable, toolsTable } from "@workspace/db";
|
|
import { sql } from "drizzle-orm";
|
|
|
|
const rawPort = process.env["PORT"];
|
|
|
|
if (!rawPort) {
|
|
throw new Error(
|
|
"PORT environment variable is required but was not provided.",
|
|
);
|
|
}
|
|
|
|
const port = Number(rawPort);
|
|
|
|
if (Number.isNaN(port) || port <= 0) {
|
|
throw new Error(`Invalid PORT value: "${rawPort}"`);
|
|
}
|
|
|
|
async function ensureSessionsTable(): Promise<void> {
|
|
try {
|
|
const exists = await db.execute(
|
|
sql`SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'sessions')`,
|
|
);
|
|
const rows = exists.rows as [{ exists: boolean }];
|
|
if (rows[0]?.exists) return;
|
|
|
|
await db.execute(
|
|
sql`CREATE TABLE "sessions" ("sid" varchar NOT NULL, "sess" json NOT NULL, "expire" timestamp(6) NOT NULL)`,
|
|
);
|
|
await db.execute(
|
|
sql`ALTER TABLE "sessions" ADD PRIMARY KEY ("sid")`,
|
|
);
|
|
logger.info("Sessions table created");
|
|
} catch (err) {
|
|
logger.error({ err }, "Failed to ensure sessions table");
|
|
}
|
|
}
|
|
|
|
async function seedAdminUser(): Promise<void> {
|
|
try {
|
|
const [row] = await db.select({ count: sql<number>`count(*)::int` }).from(usersTable);
|
|
if ((row?.count ?? 0) > 0) return;
|
|
|
|
const adminUsername = process.env.LOCAL_ADMIN_USERNAME || "admin";
|
|
const adminPassword = process.env.LOCAL_ADMIN_PASSWORD;
|
|
if (!adminPassword) {
|
|
logger.warn("LOCAL_ADMIN_PASSWORD is not set — skipping admin seed. Set it to enable local login.");
|
|
return;
|
|
}
|
|
|
|
const passwordHash = await bcrypt.hash(adminPassword, 10);
|
|
await db.insert(usersTable).values({
|
|
username: adminUsername,
|
|
passwordHash,
|
|
role: "admin",
|
|
tier: "enterprise",
|
|
});
|
|
logger.info({ username: adminUsername }, "Admin user created");
|
|
} catch (err) {
|
|
logger.error({ err }, "Failed to seed admin user");
|
|
}
|
|
}
|
|
|
|
async function ensureToolRelationsTable(): Promise<void> {
|
|
try {
|
|
const exists = await db.execute(
|
|
sql`SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'tool_relations')`,
|
|
);
|
|
const rows = exists.rows as [{ exists: boolean }];
|
|
if (rows[0]?.exists) return;
|
|
|
|
await db.execute(
|
|
sql`CREATE TABLE "tool_relations" ("id" serial NOT NULL, "tool_id" integer NOT NULL REFERENCES "tools"("id") ON DELETE CASCADE, "related_tool_id" integer NOT NULL REFERENCES "tools"("id") ON DELETE CASCADE, "relation_type" text NOT NULL DEFAULT 'similar', "notes" text, "created_by" integer REFERENCES "users"("id"), "created_at" timestamp with time zone NOT NULL DEFAULT NOW())`,
|
|
);
|
|
await db.execute(sql`ALTER TABLE "tool_relations" ADD PRIMARY KEY ("id")`);
|
|
logger.info("tool_relations table created");
|
|
} catch (err) {
|
|
logger.error({ err }, "Failed to ensure tool_relations table");
|
|
}
|
|
}
|
|
|
|
async function ensureUserColumns(): Promise<void> {
|
|
try {
|
|
const rows = (await db.execute(
|
|
sql`SELECT column_name, is_nullable FROM information_schema.columns WHERE table_name = 'users'`,
|
|
)).rows as { column_name: string; is_nullable: string }[];
|
|
const existing = new Map(rows.map((r) => [r.column_name, r.is_nullable]));
|
|
|
|
if (!existing.has("tier")) {
|
|
await db.execute(sql`ALTER TABLE "users" ADD COLUMN "tier" text NOT NULL DEFAULT 'free'`);
|
|
logger.info("Added column: tier");
|
|
}
|
|
if (!existing.has("auth_provider")) {
|
|
await db.execute(sql`ALTER TABLE "users" ADD COLUMN "auth_provider" text NOT NULL DEFAULT 'local'`);
|
|
logger.info("Added column: auth_provider");
|
|
}
|
|
if (!existing.has("auth_provider_id")) {
|
|
await db.execute(sql`ALTER TABLE "users" ADD COLUMN "auth_provider_id" text`);
|
|
logger.info("Added column: auth_provider_id");
|
|
}
|
|
if (!existing.has("display_name")) {
|
|
await db.execute(sql`ALTER TABLE "users" ADD COLUMN "display_name" text`);
|
|
logger.info("Added column: display_name");
|
|
}
|
|
if (!existing.has("password_hash")) {
|
|
await db.execute(sql`ALTER TABLE "users" ADD COLUMN "password_hash" text`);
|
|
logger.info("Added column: password_hash");
|
|
} else if (existing.get("password_hash") === "NO") {
|
|
await db.execute(sql`ALTER TABLE "users" ALTER COLUMN "password_hash" DROP NOT NULL`);
|
|
logger.info("Made password_hash nullable");
|
|
}
|
|
} catch (err) {
|
|
logger.error({ err }, "Failed to ensure user columns");
|
|
}
|
|
}
|
|
|
|
async function ensureToolCostsTable(): Promise<void> {
|
|
try {
|
|
const exists = await db.execute(
|
|
sql`SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'tool_costs')`,
|
|
);
|
|
const rows = exists.rows as [{ exists: boolean }];
|
|
if (rows[0]?.exists) return;
|
|
|
|
await db.execute(
|
|
sql`CREATE TABLE "tool_costs" ("id" serial NOT NULL, "tool_id" integer NOT NULL REFERENCES "tools"("id") ON DELETE CASCADE, "license_type" text NOT NULL DEFAULT 'free', "cost" numeric(10,2), "currency" text DEFAULT 'EUR', "renewal_date" timestamp with time zone, "notes" text, "created_by" integer REFERENCES "users"("id"), "created_at" timestamp with time zone NOT NULL DEFAULT NOW())`,
|
|
);
|
|
await db.execute(sql`ALTER TABLE "tool_costs" ADD PRIMARY KEY ("id")`);
|
|
logger.info("tool_costs table created");
|
|
} catch (err) {
|
|
logger.error({ err }, "Failed to ensure tool_costs table");
|
|
}
|
|
}
|
|
|
|
async function ensureVoterTokenColumn(): Promise<void> {
|
|
try {
|
|
const exists = await db.execute(
|
|
sql`SELECT EXISTS (SELECT FROM information_schema.columns WHERE table_name = 'ratings' AND column_name = 'voter_token')`,
|
|
);
|
|
const rows = exists.rows as [{ exists: boolean }];
|
|
if (rows[0]?.exists) return;
|
|
|
|
await db.execute(sql`ALTER TABLE "ratings" ADD COLUMN "voter_token" text`);
|
|
logger.info("voter_token column added to ratings");
|
|
} catch (err) {
|
|
logger.error({ err }, "Failed to add voter_token column");
|
|
}
|
|
}
|
|
|
|
async function ensureBillingPeriodColumn(): Promise<void> {
|
|
try {
|
|
const exists = await db.execute(
|
|
sql`SELECT EXISTS (SELECT FROM information_schema.columns WHERE table_name = 'tool_costs' AND column_name = 'billing_period')`,
|
|
);
|
|
const rows = exists.rows as [{ exists: boolean }];
|
|
if (rows[0]?.exists) return;
|
|
await db.execute(sql`ALTER TABLE "tool_costs" ADD COLUMN "billing_period" text`);
|
|
logger.info("billing_period column added to tool_costs");
|
|
} catch (err) {
|
|
logger.error({ err }, "Failed to add billing_period column");
|
|
}
|
|
}
|
|
|
|
async function ensureAdminTier(): Promise<void> {
|
|
try {
|
|
const result = await db
|
|
.update(usersTable)
|
|
.set({ tier: "enterprise" })
|
|
.where(sql`role = 'admin' AND (tier IS NULL OR tier = 'free')`)
|
|
.returning({ id: usersTable.id, username: usersTable.username });
|
|
if (result.length > 0) {
|
|
logger.info({ count: result.length }, "Admin users upgraded to enterprise tier");
|
|
}
|
|
} catch (err) {
|
|
logger.error({ err }, "Failed to upgrade admin tier");
|
|
}
|
|
}
|
|
|
|
async function purgeTrash(): Promise<void> {
|
|
const retentionDays = Number(process.env["TRASH_RETENTION_DAYS"] ?? "0");
|
|
if (!Number.isFinite(retentionDays) || retentionDays <= 0) return;
|
|
try {
|
|
const cutoff = new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1000);
|
|
const result = await db
|
|
.delete(toolsTable)
|
|
.where(sql`${toolsTable.deletedAt} IS NOT NULL AND ${toolsTable.deletedAt} < ${cutoff}`)
|
|
.returning({ id: toolsTable.id });
|
|
if (result.length > 0) {
|
|
logger.info({ count: result.length }, "Purged expired trash entries");
|
|
}
|
|
} catch (err) {
|
|
logger.error({ err }, "Failed to purge trash");
|
|
}
|
|
}
|
|
|
|
async function start(): Promise<void> {
|
|
await ensureSessionsTable();
|
|
await ensureToolRelationsTable();
|
|
await ensureToolCostsTable();
|
|
await ensureVoterTokenColumn();
|
|
await ensureBillingPeriodColumn();
|
|
await ensureUserColumns();
|
|
await seedAdminUser();
|
|
await ensureAdminTier();
|
|
await purgeTrash();
|
|
|
|
const retentionDays = Number(process.env["TRASH_RETENTION_DAYS"] ?? "0");
|
|
if (Number.isFinite(retentionDays) && retentionDays > 0) {
|
|
setInterval(() => {
|
|
void purgeTrash();
|
|
}, 60 * 60 * 1000);
|
|
logger.info({ retentionDays }, "Trash auto-purge enabled");
|
|
}
|
|
|
|
app.listen(port, (err) => {
|
|
if (err) {
|
|
logger.error({ err }, "Error listening on port");
|
|
process.exit(1);
|
|
}
|
|
logger.info({ port }, "Server listening");
|
|
});
|
|
}
|
|
start().catch((err) => {
|
|
logger.error({ err }, "Server startup failed");
|
|
process.exit(1);
|
|
});
|