Files
gerbeur/api/model/user.ts
khannurien f3f07ed1e3
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 49s
Build and Deploy Static Documentation Site / build-and-push (push) Successful in 22s
v3: every doc opens with the role, then the mechanism, then the details — the docs were written bottom-up: what a thing does first ("Declares the three contexts WSProvider fills…"), details that only make sense once its purpose is known next, and the reason it exists last ("…so that a feed doesn't re-render"), so a reader had nothing to hang the first sentences on. The README's rule caused it, since a module doc "opened with what the module does"; it now opens with the module's role in the app, and the value-side phrasing of a type ("Returned to Y by X") comes once the role is said, with WSContext as the before and after. WSContext.ts is rewritten that way throughout, and so are the docs elsewhere that buried their purpose in a trailing "so that" (the migrations, og, image-proxy, public-address, useFading, useFeedCache, the drafts), opened on a generic verb that hid it ("Holds", "Declares", "Loads", "Defines", "Types": the contexts and providers, the shared types, the models, the hooks), or were label fragments ("The page of a dump…", "A row of tabs."). A function whose verb is its action ("Stores session"), a props or result type, and a protocol message ("Sent by X when Y") already say their role and are left as they are. Only comments changed, and the catalogs only move their references; deno fmt, deno lint and doc:lint are clean.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 21:42:08 +00:00

132 lines
4.0 KiB
TypeScript

/**
* Turns the rows of `users`, once checked, into 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,
};
}