Files
Termix/src/backend/database/repositories/user-repository.ts
T

244 lines
7.9 KiB
TypeScript

import { and, asc, eq, getTableColumns, inArray, like, sql } from "drizzle-orm";
import { pluginSettings, users } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import {
countValue,
rowsAffected,
supportsReturning,
} from "./mutation-result.js";
import { insertReturning, updateReturning } from "./returning.js";
/** isExternal: the user has at least one external sign-in identity. */
export type UserRecord = typeof users.$inferSelect & { isExternal: boolean };
export type NewUserRecord = typeof users.$inferInsert;
export type UserUpdate = Partial<Omit<NewUserRecord, "id">>;
export type NewFirstLocalUserRecord = Omit<NewUserRecord, "isAdmin">;
// Qualified by hand: drizzle prints a column in a subquery without its table,
// which would bind users.id to the identity row's own id.
const userColumns = {
...getTableColumns(users),
isExternal:
sql<boolean>`EXISTS (SELECT 1 FROM ${sql.identifier("user_external_identities")} WHERE ${sql.identifier("user_external_identities")}.${sql.identifier("user_id")} = ${sql.identifier("users")}.${sql.identifier("id")})`.mapWith(
(value: unknown) => value === true || Number(value) === 1,
),
};
export class UserRepository {
constructor(
private readonly context: DatabaseContext,
private readonly onWrite?: () => void | Promise<void>,
) {}
async listAll(): Promise<UserRecord[]> {
return this.context.drizzle.select(userColumns).from(users);
}
/**
* One page of users, optionally filtered by username.
*
* The admin panel used to render every account at once, which is fine at
* home and not fine on a directory-backed install with thousands of them.
* Sorted by username so paging is stable between requests.
*/
async listPage(input: {
search?: string;
limit: number;
offset: number;
}): Promise<{ users: UserRecord[]; total: number }> {
const term = input.search?.trim();
const where = term
? like(sql`lower(${users.username})`, `%${term.toLowerCase()}%`)
: undefined;
const [rows, totalResult] = await Promise.all([
this.context.drizzle
.select(userColumns)
.from(users)
.where(where)
// Case-insensitive: a plain sort puts every capitalised name ahead of
// every lowercase one, which reads as unordered in the admin list.
.orderBy(asc(sql`lower(${users.username})`))
.limit(input.limit)
.offset(input.offset),
this.context.drizzle
.select({ count: sql<number>`COUNT(*)` })
.from(users)
.where(where),
]);
return { users: rows, total: countValue(totalResult[0]?.count) };
}
async findById(id: string): Promise<UserRecord | null> {
const rows = await this.context.drizzle
.select(userColumns)
.from(users)
.where(eq(users.id, id))
.limit(1);
return rows[0] ?? null;
}
async findByUsername(username: string): Promise<UserRecord | null> {
const rows = await this.context.drizzle
.select(userColumns)
.from(users)
.where(eq(users.username, username))
.limit(1);
return rows[0] ?? null;
}
async listByIds(ids: string[]): Promise<UserRecord[]> {
const uniqueIds = Array.from(new Set(ids.filter(Boolean)));
if (uniqueIds.length === 0) {
return [];
}
return this.context.drizzle
.select(userColumns)
.from(users)
.where(inArray(users.id, uniqueIds));
}
async create(user: NewUserRecord): Promise<UserRecord> {
const rows = await insertReturning(this.context, users, user);
await this.afterWrite();
return (
(await this.findById(rows[0].id)) ?? { ...rows[0], isExternal: false }
);
}
async createFirstLocalUser(
user: NewFirstLocalUserRecord,
): Promise<{ user: UserRecord; isFirstUser: boolean }> {
const result = await this.createCheckingIfFirst((isFirstUser) => ({
...user,
isAdmin: isFirstUser,
}));
await this.afterWrite();
return { ...result, user: { ...result.user, isExternal: false } };
}
async createFirstSsoUser(
user: NewUserRecord,
): Promise<{ user: UserRecord; isFirstUser: boolean }> {
const result = await this.createCheckingIfFirst((isFirstUser) => ({
...user,
isAdmin: isFirstUser || Boolean(user.isAdmin),
}));
await this.afterWrite();
return { ...result, user: { ...result.user, isExternal: true } };
}
/**
* Creates a user, making them an admin if the table was empty.
*
* The check and the insert have to be one transaction: two people signing up
* at once would otherwise both see an empty table and both become admin.
*
* The two branches are not a style choice. better-sqlite3 is synchronous and
* rejects an async transaction callback outright — "Transaction function
* cannot return a promise" — so a single body cannot serve both. It fails
* loudly rather than silently skipping the write, which is the one mercy here.
*/
private async createCheckingIfFirst(
build: (isFirstUser: boolean) => NewUserRecord,
): Promise<{ user: typeof users.$inferSelect; isFirstUser: boolean }> {
if (this.context.dialect === "sqlite") {
/* eslint-disable no-restricted-syntax -- sqlite-only branch: the dialect
is checked directly above, and better-sqlite3 rejects an async
transaction callback, so this cannot use the shared helpers. */
return this.context.drizzle.transaction((tx) => {
const isFirstUser =
tx.select({ id: users.id }).from(users).all().length === 0;
const rows = tx
.insert(users)
.values(build(isFirstUser))
.returning()
.all();
return { user: rows[0], isFirstUser };
});
/* eslint-enable no-restricted-syntax */
}
return this.context.drizzle.transaction(async (tx) => {
const existing = await tx.select({ id: users.id }).from(users);
const isFirstUser = existing.length === 0;
const values = build(isFirstUser);
if (supportsReturning(this.context.dialect)) {
// eslint-disable-next-line no-restricted-syntax -- guarded by the check on this line
const rows = await tx.insert(users).values(values).returning();
return { user: rows[0], isFirstUser };
}
// users is keyed by a text id the caller supplies, so there is something
// to read back by even without RETURNING.
await tx.insert(users).values(values);
const [user] = await tx
.select()
.from(users)
.where(eq(users.id, values.id));
return { user, isFirstUser };
});
}
async update(id: string, update: UserUpdate): Promise<UserRecord | null> {
const rows = await updateReturning(
this.context,
users,
update,
eq(users.id, id),
);
await this.afterWrite();
return rows[0] ? this.findById(rows[0].id) : null;
}
async delete(id: string): Promise<boolean> {
// plugin_settings.scope_id is polymorphic, so it carries no foreign key to
// users and the engine will not cascade it. Removed here rather than in a
// route so every caller that deletes a user is covered.
await this.context.drizzle
.delete(pluginSettings)
.where(
and(
inArray(pluginSettings.scope, ["user", "secret"]),
eq(pluginSettings.scopeId, id),
),
);
const result = await this.context.drizzle
.delete(users)
.where(eq(users.id, id));
await this.afterWrite();
return rowsAffected(result) > 0;
}
async countAdmins(): Promise<number> {
const rows = await this.context.drizzle
.select({ id: users.id })
.from(users)
.where(eq(users.isAdmin, true));
return rows.length;
}
async countAll(): Promise<number> {
const rows = await this.context.drizzle
.select({ id: users.id })
.from(users);
return rows.length;
}
private async afterWrite(): Promise<void> {
await this.onWrite?.();
}
}