/** * Types and guards the rows of `users`, and converts them to the {@link User} * that `user-service.ts` returns. A route turns a `User` into what it may send * with {@link toPublicUser} or {@link toSelfUser}. * * @module */ import type { SQLOutputValue } from "node:sqlite"; import { isRole, type PublicUser, type Role, type SelfUser, } from "../../shared/user.ts"; /** * Returned by the lookups of `user-service.ts`. It must never be sent as is: * it carries the password hash. */ export interface User extends SelfUser { /** From `hashPassword`, as `hash.salt`. */ passwordHash: string; } /** * Returns what anyone may see of `user`, without the hash and the email. Every * route that sends another user's profile goes through it. */ export function toPublicUser( { passwordHash: _, email: _email, ...user }: User, ): PublicUser { return user; } /** * Returns what `user` sees of themselves, without the hash, for the routes * that answer the caller's own account. */ export function toSelfUser({ passwordHash: _, ...user }: User): SelfUser { return user; } /** * Selected by `user-service.ts` and `follow-service.ts`, and converted by * {@link userRowToApi}. It leaves out `chat_read_at` and `tokens_valid_after`, * which only the chat's read state and `getSessionState` query. */ export interface UserRow { id: string; username: string; password_hash: string; role: Role; created_at: string; /** Bumped by any profile or avatar change. */ updated_at: string | null; /** Null without an avatar. */ avatar_mime: string | null; /** Markdown. */ description: string | null; /** Null for the seeded admin, who had no inviter. */ invited_by: string | null; /** * Joined from the inviter's row rather than stored, and undefined when the * query doesn't join it. The guard doesn't check it. */ invited_by_username: string | null; email: string; [key: string]: SQLOutputValue; } /** * Checks whether `obj`, a row selected with the columns of {@link UserRow}, is * one. */ export function isUserRow(obj: unknown): obj is UserRow { return !!obj && typeof obj === "object" && "id" in obj && typeof obj.id === "string" && "username" in obj && typeof obj.username === "string" && "password_hash" in obj && typeof obj.password_hash === "string" && "role" in obj && isRole(obj.role) && "created_at" in obj && typeof obj.created_at === "string" && "updated_at" in obj && (typeof obj.updated_at === "string" || obj.updated_at === null) && "avatar_mime" in obj && (typeof obj.avatar_mime === "string" || obj.avatar_mime === null) && "description" in obj && (typeof obj.description === "string" || obj.description === null) && "invited_by" in obj && (typeof obj.invited_by === "string" || obj.invited_by === null) && "email" in obj && typeof obj.email === "string"; } /** Converts a row to a {@link User}, turning nulls into undefined. */ export function userRowToApi(row: UserRow): User { return { id: row.id, username: row.username, passwordHash: row.password_hash, role: row.role, createdAt: new Date(row.created_at), updatedAt: row.updated_at ? new Date(row.updated_at) : undefined, avatarMime: row.avatar_mime ?? undefined, description: row.description ?? undefined, invitedByUsername: typeof row.invited_by_username === "string" ? row.invited_by_username : undefined, email: row.email, }; } /** * Converts a {@link User} back to a row, for `updateUser`. A `User` has no * inviter id, so `invited_by` comes out null: don't write it back. */ export function userApiToRow(user: User): UserRow { return { id: user.id, username: user.username, password_hash: user.passwordHash, role: user.role, created_at: user.createdAt.toISOString(), updated_at: user.updatedAt?.toISOString() ?? null, avatar_mime: user.avatarMime ?? null, description: user.description ?? null, invited_by: null, invited_by_username: null, email: user.email, }; }