feat(db): add admin_users schema and repository with create-admin script [skip ci]

This commit is contained in:
coo1estguy
2026-09-14 23:07:39 +05:30
parent 35190585d7
commit b5b5db0136
6 changed files with 183 additions and 1 deletions
+3
View File
@@ -43,6 +43,9 @@ export const baseConfigSchema = z.object({
// Windows puts a trailing space in the variable, which otherwise turns every
// admin request into a 401 that reads like a wrong key.
ADMIN_API_KEY: z.string().trim().optional(),
ADMIN_EMAIL: z.string().trim().email().optional(),
ADMIN_PASSWORD: z.string().trim().min(6).optional(),
ADMIN_USERNAME: z.string().trim().optional(),
WORKER_CONCURRENCY: z.coerce.number().int().min(1).default(10),
QUEUE_MAX_LEN: z.coerce.number().int().min(1).default(10000000),
DB_MAX_CONNECTIONS: z.coerce.number().int().min(1).default(2),
+13
View File
@@ -367,8 +367,21 @@ export const scheduledPayloads = pgTable("scheduled_payloads", {
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
});
export const adminUsers = pgTable("admin_users", {
id: uuid("id").primaryKey().defaultRandom(),
email: varchar("email").notNull().unique(),
username: varchar("username").unique(),
passwordHash: varchar("password_hash").notNull(),
role: varchar("role").notNull().default("admin"),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
});
// ─── Generated Zod Schemas ──────────────────────────────────────────────────
export const insertAdminUserSchema = createInsertSchema(adminUsers);
export const selectAdminUserSchema = createSelectSchema(adminUsers);
export const insertProjectSchema = createInsertSchema(projects);
export const selectProjectSchema = createSelectSchema(projects);
+82 -1
View File
@@ -1,4 +1,4 @@
import { eq, and, sql as drizzleSql, inArray, desc } from "drizzle-orm";
import { eq, and, or, sql as drizzleSql, inArray, desc } from "drizzle-orm";
import type { Db } from "@/index.js";
import type { Preferences, ContactChannel } from "@/contracts/index.js";
import {
@@ -18,6 +18,7 @@ import {
projectApiKeys,
suppressions,
messageLogs,
adminUsers,
} from "@/db/schema.js";
// ─── Domain types ───────────────────────────────────────────────────────────
@@ -1244,3 +1245,83 @@ export class SegmentRepository {
return (rows as any[]).map((r) => r.segment);
}
}
// ─── AdminUserRepository ───────────────────────────────────────────────────
export interface AdminUserRecord {
id: string;
email: string;
username: string | null;
passwordHash: string;
role: string;
createdAt: Date;
updatedAt: Date;
}
export class AdminUserRepository {
constructor(private readonly db: Db) {}
async findByEmailOrUsername(identifier: string): Promise<AdminUserRecord | null> {
const normalized = identifier.trim().toLowerCase();
const rows = await this.db
.select()
.from(adminUsers)
.where(or(eq(adminUsers.email, normalized), eq(adminUsers.username, identifier.trim())))
.limit(1);
return (rows[0] as AdminUserRecord) ?? null;
}
async findById(id: string): Promise<AdminUserRecord | null> {
const rows = await this.db.select().from(adminUsers).where(eq(adminUsers.id, id)).limit(1);
return (rows[0] as AdminUserRecord) ?? null;
}
async create(data: {
email: string;
username?: string | null;
passwordHash: string;
role?: string;
}): Promise<AdminUserRecord> {
const rows = await this.db
.insert(adminUsers)
.values({
email: data.email.trim().toLowerCase(),
username: data.username ? data.username.trim() : null,
passwordHash: data.passwordHash,
role: data.role || "admin",
})
.returning();
return rows[0] as AdminUserRecord;
}
async updatePassword(id: string, passwordHash: string): Promise<boolean> {
const rows = await this.db
.update(adminUsers)
.set({ passwordHash, updatedAt: new Date() })
.where(eq(adminUsers.id, id))
.returning();
return rows.length > 0;
}
async list(): Promise<Omit<AdminUserRecord, "passwordHash">[]> {
const rows = await this.db
.select({
id: adminUsers.id,
email: adminUsers.email,
username: adminUsers.username,
role: adminUsers.role,
createdAt: adminUsers.createdAt,
updatedAt: adminUsers.updatedAt,
})
.from(adminUsers)
.orderBy(adminUsers.createdAt);
return rows as Omit<AdminUserRecord, "passwordHash">[];
}
async count(): Promise<number> {
const rows = await this.db
.select({ count: drizzleSql<number>`count(*)::int` })
.from(adminUsers);
return rows[0]?.count ?? 0;
}
}