v3: comments say what the code can't — the documentation pass wanted JSDoc on every export and every field of an exported interface, which filled the code with restatements ("The dump.", "Whether it is loading.", "The props of X.", "Renders the X.") and module docs that told a reader nothing ("The hot and new feeds, and the props every main feed of the index takes."); some were wrong as well as vague. Every comment in api/, shared/ and src/ is rewritten under one rule, now stated in the README's "Comments" section: every module and export still has a JSDoc, and it gives context (who uses it and where), usage (what a caller must know: a precondition, what it leaves to the caller, a side effect) or its place in the chain (where its input comes from, where its output goes); a field, parameter, private function or local gets one only when it adds to its parent's, and context about a whole type goes on the type; a type's doc is phrased from the value's side ("Returned to Y by X when Z", "Passed to Y by X"); full sentences, as short as they can be, no label-colon-list; // goes wherever the code isn't obvious and says why, and the section banners and narration go. Every claim was checked against the code, which turned up some forty comments that were wrong: DumpCard's claimed it was only used by the hot and new feeds, the WebSocket welcome claimed to be a socket's first message (presence comes first), UpdateUserRequest said null removes a description (it keeps it), deletedDumpIds leaves out that it also holds dumps made private, THEME_COLOR claimed to mirror index.html's, and the like. The routers' JSDoc now says where they are mounted and what becomes of a thrown APIException. doc-lint.ts lets a field without a doc through; a module or export without one still fails. No code changes; 204 backend and 628 frontend tests pass, and doc:lint, deno check, deno lint and tsc are clean.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,35 +1,35 @@
|
||||
/**
|
||||
* The payloads of the tokens the server signs, and their guards.
|
||||
* Types the payloads of session and password reset tokens, and guards them.
|
||||
* `lib/jwt.ts` signs the tokens and runs the guards when it verifies one.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { isRole, type Role } from "../../shared/user.ts";
|
||||
|
||||
/** What a session token carries. */
|
||||
/**
|
||||
* Signed into a session token by `createJWT`, and returned by `authenticate`
|
||||
* to the auth middleware as `ctx.state.user`. By then `role` is the user's live
|
||||
* role, while `username` is still the one at issue time.
|
||||
*/
|
||||
export interface AuthPayload {
|
||||
/** The user's id. */
|
||||
userId: string;
|
||||
/** The user's name when the token was issued. */
|
||||
username: string;
|
||||
/**
|
||||
* The user's role; the live one replaces it once the token is authenticated.
|
||||
*/
|
||||
role: Role;
|
||||
/** When the token expires, in seconds since the epoch. */
|
||||
/** Seconds since the epoch. */
|
||||
exp: number;
|
||||
/** When the token was issued, in seconds since the epoch. */
|
||||
/** Seconds since the epoch, compared with `users.tokens_valid_after`. */
|
||||
iat?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether `obj` is an {@link AuthPayload}, and not a purpose-scoped
|
||||
* token's payload.
|
||||
* Checks whether `obj` is an {@link AuthPayload}. A media or reset token's
|
||||
* payload, which has a `purpose`, never is.
|
||||
*/
|
||||
export function isAuthPayload(obj: unknown): obj is AuthPayload {
|
||||
return !!obj &&
|
||||
typeof obj === "object" &&
|
||||
// Purpose-scoped tokens (password reset, media) are never session tokens.
|
||||
// A media or reset token must not pass for a session token.
|
||||
!("purpose" in obj) &&
|
||||
"userId" in obj && typeof obj.userId === "string" &&
|
||||
"username" in obj && typeof obj.username === "string" &&
|
||||
@@ -37,17 +37,21 @@ export function isAuthPayload(obj: unknown): obj is AuthPayload {
|
||||
"exp" in obj && typeof obj.exp === "number";
|
||||
}
|
||||
|
||||
/** What a password reset token carries. */
|
||||
/**
|
||||
* Signed into a reset link's token by `createPasswordResetToken`, and returned
|
||||
* by `verifyPasswordResetToken` to `resetPassword`.
|
||||
*/
|
||||
export interface PasswordResetPayload {
|
||||
/** The token's purpose, which makes it a reset token. */
|
||||
purpose: "password-reset";
|
||||
/** The id of the user whose password it resets. */
|
||||
userId: string;
|
||||
/** When the token expires, in seconds since the epoch. */
|
||||
/** Seconds since the epoch. */
|
||||
exp: number;
|
||||
}
|
||||
|
||||
/** Checks whether `obj` is a {@link PasswordResetPayload}. */
|
||||
/**
|
||||
* Checks whether `obj` is a {@link PasswordResetPayload}. It doesn't check
|
||||
* `exp`, which `jwtVerify` has already enforced.
|
||||
*/
|
||||
export function isPasswordResetPayload(
|
||||
obj: unknown,
|
||||
): obj is PasswordResetPayload {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* The `categories` table: its row type, its guard, and its mapper to the API's
|
||||
* {@link Category}.
|
||||
* Types the rows the category service reads from `categories`, and turns them
|
||||
* into the {@link Category} the category routes send.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -8,25 +8,19 @@
|
||||
import type { SQLOutputValue } from "node:sqlite";
|
||||
import type { Category } from "../../shared/category.ts";
|
||||
|
||||
/** A row of the `categories` table. */
|
||||
/** Returned by the category service's `SELECT`s on `categories`. */
|
||||
export interface CategoryRow {
|
||||
/** The category's id. */
|
||||
id: string;
|
||||
/** The category's URL slug, at the site root. */
|
||||
slug: string;
|
||||
/** The category's display name. */
|
||||
name: string;
|
||||
/** The category's place in the list, ascending. */
|
||||
/** Sorts the categories, lowest first. */
|
||||
position: number;
|
||||
/** When the category was created. */
|
||||
created_at: string;
|
||||
/** When the category was last changed, if ever. */
|
||||
updated_at: string | null;
|
||||
/** Any other column the query selects. */
|
||||
[key: string]: SQLOutputValue;
|
||||
}
|
||||
|
||||
/** Checks whether `obj` is a {@link CategoryRow}. */
|
||||
/** Checks whether a row `node:sqlite` returned is a {@link CategoryRow}. */
|
||||
export function isCategoryRow(obj: unknown): obj is CategoryRow {
|
||||
return !!obj && typeof obj === "object" &&
|
||||
"id" in obj && typeof obj.id === "string" &&
|
||||
@@ -38,7 +32,7 @@ export function isCategoryRow(obj: unknown): obj is CategoryRow {
|
||||
(typeof obj.updated_at === "string" || obj.updated_at === null);
|
||||
}
|
||||
|
||||
/** Converts a row to the API's {@link Category}. */
|
||||
/** Converts `row` to the {@link Category} the category routes send. */
|
||||
export function categoryRowToApi(row: CategoryRow): Category {
|
||||
return {
|
||||
id: row.id,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* The `chat_messages` table: its row type, its guard, and its mapper to the
|
||||
* API's {@link ChatMessage}.
|
||||
* Maps the rows of `chat_messages` to the {@link ChatMessage} the API sends.
|
||||
* Only `services/chat-service.ts` uses it, on the rows of its one query.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -9,44 +9,30 @@ import type { SQLOutputValue } from "node:sqlite";
|
||||
import type { ChatMessage } from "../../shared/chat.ts";
|
||||
|
||||
/**
|
||||
* A row of the `chat_messages` table, joined with its author and, when it is
|
||||
* a reply, the replied-to message and its author.
|
||||
* Returned by chat-service's query, which joins each message to its author and
|
||||
* to the message it replies to. `reply_to_id` is kept when the message it
|
||||
* points to is deleted, while `reply_to_author` and `reply_to_body` then read
|
||||
* null.
|
||||
*/
|
||||
export interface ChatMessageRow {
|
||||
/** The message's id. */
|
||||
id: string;
|
||||
/** The author's id. */
|
||||
user_id: string;
|
||||
/** The message, in Markdown. */
|
||||
body: string;
|
||||
/** When the message was sent. */
|
||||
created_at: string;
|
||||
/** When the message was last edited, if ever. */
|
||||
updated_at: string | null;
|
||||
/** The author's name (joined). */
|
||||
author_username: string;
|
||||
/** The author's avatar type, null without one (joined). */
|
||||
author_avatar_mime: string | null;
|
||||
/**
|
||||
* The id of the message this replies to, kept even once that message is
|
||||
* deleted.
|
||||
*/
|
||||
reply_to_id: string | null;
|
||||
/**
|
||||
* The replied-to message's author (joined); null when there's no reply or it
|
||||
* was since deleted.
|
||||
*/
|
||||
reply_to_author: string | null;
|
||||
/**
|
||||
* The start of the replied-to message (joined); null when there's no reply or
|
||||
* it was since deleted.
|
||||
*/
|
||||
/** Cut to chat-service's `REPLY_SNIPPET_LEN` characters. */
|
||||
reply_to_body: string | null;
|
||||
/** Any other column the query selects. */
|
||||
[key: string]: SQLOutputValue;
|
||||
}
|
||||
|
||||
/** Checks whether `obj` is a {@link ChatMessageRow}. */
|
||||
/**
|
||||
* Checks whether `obj`, a row read by chat-service, is a
|
||||
* {@link ChatMessageRow}.
|
||||
*/
|
||||
export function isChatMessageRow(obj: unknown): obj is ChatMessageRow {
|
||||
return !!obj && typeof obj === "object" &&
|
||||
"id" in obj && typeof obj.id === "string" &&
|
||||
@@ -67,7 +53,10 @@ export function isChatMessageRow(obj: unknown): obj is ChatMessageRow {
|
||||
(typeof obj.reply_to_body === "string" || obj.reply_to_body === null);
|
||||
}
|
||||
|
||||
/** Converts a row to the API's {@link ChatMessage}. */
|
||||
/**
|
||||
* Converts a row to the {@link ChatMessage} that chat-service returns, turning
|
||||
* its timestamps into Dates and its nulls into absent fields.
|
||||
*/
|
||||
export function chatMessageRowToApi(row: ChatMessageRow): ChatMessage {
|
||||
return {
|
||||
id: row.id,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* The `comments` table: its row type, its guard, and its mapper to the API's
|
||||
* {@link Comment}.
|
||||
* Types the rows the comment service reads from `comments`, and turns them into
|
||||
* the {@link Comment} the comment routes send.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -8,38 +8,31 @@
|
||||
import type { SQLOutputValue } from "node:sqlite";
|
||||
import type { Comment } from "../../shared/comment.ts";
|
||||
|
||||
/** A row of the `comments` table, joined with its author. */
|
||||
/**
|
||||
* Returned by the comment service's `SELECT`s on `comments`, joined with
|
||||
* `users` for the `author_` columns.
|
||||
*/
|
||||
export interface CommentRow {
|
||||
/** The comment's id. */
|
||||
id: string;
|
||||
/** The id of the dump it is posted under. */
|
||||
dump_id: string;
|
||||
/** The author's id. */
|
||||
user_id: string;
|
||||
/** The id of the comment it replies to; null at the top level. */
|
||||
/** It is null on a top-level comment. */
|
||||
parent_id: string | null;
|
||||
/** The comment, in Markdown. */
|
||||
body: string;
|
||||
/** When the comment was posted. */
|
||||
created_at: string;
|
||||
/** When the comment was last edited, if ever. */
|
||||
updated_at: string | null;
|
||||
/**
|
||||
* 1 once deleted: the body is emptied but the row stays, so its replies
|
||||
* keep their place.
|
||||
* 1 once deleted. The body is emptied but the row stays, so its replies keep
|
||||
* their place in the thread.
|
||||
*/
|
||||
deleted: number;
|
||||
/** How many users like the comment. */
|
||||
like_count: number;
|
||||
/** The author's name (joined). */
|
||||
author_username: string;
|
||||
/** The author's avatar type, null without one (joined). */
|
||||
author_avatar_mime: string | null;
|
||||
/** Any other column the query selects. */
|
||||
[key: string]: SQLOutputValue;
|
||||
}
|
||||
|
||||
/** Checks whether `obj` is a {@link CommentRow}. */
|
||||
/** Checks whether a row `node:sqlite` returned is a {@link CommentRow}. */
|
||||
export function isCommentRow(
|
||||
obj: unknown,
|
||||
): obj is CommentRow {
|
||||
@@ -61,7 +54,10 @@ export function isCommentRow(
|
||||
obj.author_avatar_mime === null);
|
||||
}
|
||||
|
||||
/** Converts a row to the API's {@link Comment}. */
|
||||
/**
|
||||
* Converts `row` to the {@link Comment} the comment routes send and the
|
||||
* WebSocket broadcasts.
|
||||
*/
|
||||
export function commentRowToApi(row: CommentRow): Comment {
|
||||
return {
|
||||
id: row.id,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* The `dumps` table: its row type, its guard, the column lists services select
|
||||
* it with, and its mappers to and from the API's {@link Dump}.
|
||||
* Maps the `dumps` table to the API's {@link Dump}. Every service that reads
|
||||
* dumps selects them with the column lists here, checks each row with
|
||||
* {@link isDumpRow} and converts it with {@link dumpRowToApi}.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -8,67 +9,69 @@
|
||||
import type { SQLOutputValue } from "node:sqlite";
|
||||
import type { Dump, RichContent } from "../../shared/dump.ts";
|
||||
|
||||
/** A row of the `dumps` table, with its live comment count. */
|
||||
/**
|
||||
* Read from the `dumps` table with {@link DUMP_SELECT_COLUMNS}, or written back
|
||||
* from a {@link Dump} by {@link dumpApiToRow}. It lacks `url_canonical`, which
|
||||
* only the duplicate lookup reads.
|
||||
*/
|
||||
export interface DumpRow {
|
||||
/** The dump's id. */
|
||||
id: string;
|
||||
/** `url` for a link, `file` for an upload. */
|
||||
kind: string;
|
||||
/** The dump's title. */
|
||||
title: string;
|
||||
/** The poster's description, in Markdown. */
|
||||
comment: string | null;
|
||||
/** The poster's id. */
|
||||
user_id: string;
|
||||
/** When the dump was posted. */
|
||||
created_at: string;
|
||||
/** When the dump was last edited, if ever. */
|
||||
updated_at: string | null;
|
||||
/** The dump's URL slug. */
|
||||
slug: string | null;
|
||||
/** The link exactly as submitted, for a URL dump. */
|
||||
/** The link as the poster submitted it. */
|
||||
url: string | null;
|
||||
/** The link's preview, as {@link RichContent} JSON. */
|
||||
/** {@link RichContent} as JSON. */
|
||||
rich_content: string | null;
|
||||
/** The uploaded file's name, for a file dump. */
|
||||
file_name: string | null;
|
||||
/** The uploaded file's type, for a file dump. */
|
||||
file_mime: string | null;
|
||||
/** The uploaded file's size in bytes, for a file dump. */
|
||||
file_size: number | null;
|
||||
/** How many users upvoted the dump. */
|
||||
vote_count: number;
|
||||
/** How many comments it has, not counting deleted ones (computed). */
|
||||
/** Computed by a subquery, which counts the comments that aren't deleted. */
|
||||
comment_count: number;
|
||||
/** 1 when only the poster sees the dump. */
|
||||
is_private: number;
|
||||
/** The uploaded thumbnail's type, null without one. */
|
||||
custom_thumbnail_mime: string | null;
|
||||
/** Any other column the query selects. */
|
||||
/** Lets a query select more columns, such as a join's. */
|
||||
[key: string]: SQLOutputValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* The stored columns of {@link DumpRow} — keep in sync with it and
|
||||
* {@link isDumpRow}. Services select with these instead of repeating the
|
||||
* column list themselves.
|
||||
* Lists the stored columns of {@link DumpRow}. A column added to the table must
|
||||
* be added here, to {@link DUMP_COLUMNS_ALIASED}, to {@link DumpRow} and to
|
||||
* {@link isDumpRow}.
|
||||
*/
|
||||
export const DUMP_COLUMNS =
|
||||
"id, kind, title, slug, comment, user_id, created_at, updated_at, url, rich_content, file_name, file_mime, file_size, vote_count, is_private, custom_thumbnail_mime";
|
||||
|
||||
/** {@link DUMP_COLUMNS}, for a query aliasing `dumps` as `d`. */
|
||||
/**
|
||||
* Lists {@link DUMP_COLUMNS} for a query that aliases `dumps` as `d`. The
|
||||
* playlist service selects with it and counts the comments itself.
|
||||
*/
|
||||
export const DUMP_COLUMNS_ALIASED =
|
||||
"d.id, d.kind, d.title, d.slug, d.comment, d.user_id, d.created_at, d.updated_at, d.url, d.rich_content, d.file_name, d.file_mime, d.file_size, d.vote_count, d.is_private, d.custom_thumbnail_mime";
|
||||
|
||||
/** {@link DUMP_COLUMNS} and the computed `comment_count`: a full {@link DumpRow}. */
|
||||
/**
|
||||
* Selects a whole {@link DumpRow}, the stored columns and the computed
|
||||
* `comment_count`.
|
||||
*/
|
||||
export const DUMP_SELECT_COLUMNS: string = `${DUMP_COLUMNS},
|
||||
(SELECT COUNT(*) FROM comments WHERE dump_id = dumps.id AND deleted = 0) as comment_count`;
|
||||
|
||||
/** {@link DUMP_SELECT_COLUMNS}, for a query aliasing `dumps` as `d`. */
|
||||
/**
|
||||
* Selects a whole {@link DumpRow} in a query that aliases `dumps` as `d` to
|
||||
* join it with other tables.
|
||||
*/
|
||||
export const DUMP_SELECT_COLUMNS_ALIASED: string = `${DUMP_COLUMNS_ALIASED},
|
||||
(SELECT COUNT(*) FROM comments WHERE dump_id = d.id AND deleted = 0) as comment_count`;
|
||||
|
||||
/** Checks whether `obj` is a {@link DumpRow}. */
|
||||
/**
|
||||
* Checks whether `obj`, a row from a query, is a {@link DumpRow}. It checks
|
||||
* each column's type, not that `kind` is `url` or `file`.
|
||||
*/
|
||||
export function isDumpRow(obj: unknown): obj is DumpRow {
|
||||
return !!obj &&
|
||||
typeof obj === "object" &&
|
||||
@@ -99,7 +102,10 @@ export function isDumpRow(obj: unknown): obj is DumpRow {
|
||||
obj.custom_thumbnail_mime === null);
|
||||
}
|
||||
|
||||
/** Converts a row to the API's {@link Dump}. */
|
||||
/**
|
||||
* Converts a row to the API's {@link Dump}, parsing its preview's JSON. It
|
||||
* leaves `categoryIds` out: the services add them with `attachCategoryIds`.
|
||||
*/
|
||||
export function dumpRowToApi(row: DumpRow): Dump {
|
||||
return {
|
||||
id: row.id,
|
||||
@@ -124,7 +130,10 @@ export function dumpRowToApi(row: DumpRow): Dump {
|
||||
};
|
||||
}
|
||||
|
||||
/** Converts a {@link Dump} back to a row. */
|
||||
/**
|
||||
* Converts a {@link Dump} back to a row, for a service that writes an edited
|
||||
* dump's columns.
|
||||
*/
|
||||
export function dumpApiToRow(dump: Dump): DumpRow {
|
||||
return {
|
||||
id: dump.id,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* The `follows` table: its row type and its guard.
|
||||
* Types and guards the rows of `follows`, which `follow-service.ts` reads to
|
||||
* answer what a user follows.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -7,25 +8,22 @@
|
||||
import type { SQLOutputValue } from "node:sqlite";
|
||||
|
||||
/**
|
||||
* A row of the `follows` table: one user following either another user or a
|
||||
* playlist.
|
||||
* Read by `getFollowStatus`. Exactly one of `followed_user_id` and
|
||||
* `followed_playlist_id` is set, as the table's CHECK constraint requires.
|
||||
*/
|
||||
export interface FollowRow {
|
||||
/** The follow's id. */
|
||||
id: string;
|
||||
/** The id of the user following. */
|
||||
follower_id: string;
|
||||
/** The id of the user followed, when it is a user. */
|
||||
followed_user_id: string | null;
|
||||
/** The id of the playlist followed, when it is a playlist. */
|
||||
followed_playlist_id: string | null;
|
||||
/** When the follow began. */
|
||||
created_at: string;
|
||||
/** Any other column the query selects. */
|
||||
[key: string]: SQLOutputValue;
|
||||
}
|
||||
|
||||
/** Checks whether `obj` is a {@link FollowRow}. */
|
||||
/**
|
||||
* Checks whether `obj`, a row `getFollowStatus` selected, is a
|
||||
* {@link FollowRow}.
|
||||
*/
|
||||
export function isFollowRow(
|
||||
obj: unknown,
|
||||
): obj is FollowRow {
|
||||
|
||||
@@ -1,26 +1,32 @@
|
||||
/**
|
||||
* The `invites` table: its row type and its guard.
|
||||
* Types and guards the rows of `invites`, which `invite-service.ts` reads to
|
||||
* check an invite before sign-up.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { SQLOutputValue } from "node:sqlite";
|
||||
|
||||
/** A row of the `invites` table. */
|
||||
/**
|
||||
* Stored by `createInvite`, and read back by `validateInvite` when the invite
|
||||
* link is opened or used to sign up.
|
||||
*/
|
||||
export interface InviteRow {
|
||||
/** The invite's token, which registering redeems. */
|
||||
/** The random token the invite link carries, which is the primary key. */
|
||||
token: string;
|
||||
/** The id of the user who made the invite. */
|
||||
/** Becomes the new user's `invited_by`. */
|
||||
inviter_id: string;
|
||||
/** When the invite was redeemed; null while unused. */
|
||||
/** Set when an account is created with the invite. */
|
||||
used_at: string | null;
|
||||
/** When the invite was made. */
|
||||
/** Starts the 7-day window in which the invite works. */
|
||||
created_at: string;
|
||||
/** Any other column the query selects. */
|
||||
[key: string]: SQLOutputValue;
|
||||
}
|
||||
|
||||
/** Checks whether `obj` is a {@link InviteRow}. */
|
||||
/**
|
||||
* Checks whether `obj`, a row `validateInvite` selected, is an
|
||||
* {@link InviteRow}.
|
||||
*/
|
||||
export function isInviteRow(
|
||||
obj: unknown,
|
||||
): obj is InviteRow {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* The `notifications` table: its row type, its guard, and its mapper to the
|
||||
* API's {@link Notification}.
|
||||
* Types and guards the rows of `notifications`, and converts them to the
|
||||
* {@link Notification} that `GET /api/notifications` answers.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -12,30 +12,33 @@ import type {
|
||||
NotificationType,
|
||||
} from "../../shared/notification.ts";
|
||||
|
||||
/** A row of the `notifications` table. */
|
||||
/**
|
||||
* Stored by the triggers of `notification-service.ts`, and read back by
|
||||
* `getNotificationsForUser`.
|
||||
*/
|
||||
export interface NotificationRow {
|
||||
/** The notification's id. */
|
||||
id: string;
|
||||
/** The id of the user notified. */
|
||||
user_id: string;
|
||||
/** The notification's {@link NotificationType}. */
|
||||
/** A {@link NotificationType}, not checked by the guard. */
|
||||
type: string;
|
||||
/** The notification's {@link NotificationData}, as JSON. */
|
||||
/** The {@link NotificationData} as JSON, not checked by the guard. */
|
||||
data: string;
|
||||
/** 1 once the user has read it. */
|
||||
/** 0 or 1. */
|
||||
read: number;
|
||||
/** When the notification was made. */
|
||||
created_at: string;
|
||||
/**
|
||||
* What caused the notification: a second one with the same
|
||||
* `(user_id, source_key)` isn't stored. Null when duplicates are allowed.
|
||||
* Names the event that caused the notification. A unique index keeps a
|
||||
* second row with the same `(user_id, source_key)` out; null allows
|
||||
* duplicates.
|
||||
*/
|
||||
source_key: string | null;
|
||||
/** Any other column the query selects. */
|
||||
[key: string]: SQLOutputValue;
|
||||
}
|
||||
|
||||
/** Checks whether `obj` is a {@link NotificationRow}. */
|
||||
/**
|
||||
* Checks whether `obj`, a row `getNotificationsForUser` selected, is a
|
||||
* {@link NotificationRow}.
|
||||
*/
|
||||
export function isNotificationRow(
|
||||
obj: unknown,
|
||||
): obj is NotificationRow {
|
||||
@@ -50,7 +53,11 @@ export function isNotificationRow(
|
||||
(typeof obj.source_key === "string" || obj.source_key === null);
|
||||
}
|
||||
|
||||
/** Converts a row to the API's {@link Notification}. */
|
||||
/**
|
||||
* Converts a row to the {@link Notification} the client gets.
|
||||
*
|
||||
* @throws {SyntaxError} When `data` isn't valid JSON.
|
||||
*/
|
||||
export function notificationRowToApi(row: NotificationRow): Notification {
|
||||
return {
|
||||
id: row.id,
|
||||
|
||||
@@ -1,26 +1,34 @@
|
||||
/**
|
||||
* The `password_reset_tokens` table: its row type and its guard.
|
||||
* Types and guards the rows of `password_reset_tokens`, which
|
||||
* `password-reset-service.ts` reads to make each reset link work once.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { SQLOutputValue } from "node:sqlite";
|
||||
|
||||
/** A row of the `password_reset_tokens` table. */
|
||||
/**
|
||||
* Stored by `requestPasswordReset` when it emails a reset link, and read back
|
||||
* by `resetPassword` when the link is used.
|
||||
*/
|
||||
export interface PasswordResetTokenRow {
|
||||
/** The reset token. */
|
||||
/** The signed token itself, which the reset link carries. */
|
||||
token: string;
|
||||
/** The id of the user whose password it resets. */
|
||||
user_id: string;
|
||||
/** When the token stops working. */
|
||||
/**
|
||||
* Lets `db/startup.ts` purge old rows. The token's own `exp` is what ends
|
||||
* the link.
|
||||
*/
|
||||
expires_at: string;
|
||||
/** When the token was used; null while unused. */
|
||||
/** Set when the link is used, and reset to null if the reset then fails. */
|
||||
used_at: string | null;
|
||||
/** Any other column the query selects. */
|
||||
[key: string]: SQLOutputValue;
|
||||
}
|
||||
|
||||
/** Checks whether `obj` is a {@link PasswordResetTokenRow}. */
|
||||
/**
|
||||
* Checks whether `obj`, a row `resetPassword` selected, is a
|
||||
* {@link PasswordResetTokenRow}.
|
||||
*/
|
||||
export function isPasswordResetTokenRow(
|
||||
obj: unknown,
|
||||
): obj is PasswordResetTokenRow {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* The `playlists` table: its row type, its guard, and its mapper to the API's
|
||||
* {@link Playlist}.
|
||||
* Types the rows the playlist and follow services read from `playlists`, and
|
||||
* turns them into the {@link Playlist} the API sends.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -9,33 +9,24 @@ import type { SQLOutputValue } from "node:sqlite";
|
||||
import type { Playlist } from "../../shared/playlist.ts";
|
||||
|
||||
/**
|
||||
* A row of the `playlists` table. Queries may add `dump_count` and
|
||||
* `owner_username`, which {@link playlistRowToApi} carries over.
|
||||
* Returned by the playlist and follow services' `SELECT`s on `playlists`. A
|
||||
* query may add `dump_count` and `owner_username` columns, which
|
||||
* {@link playlistRowToApi} carries over when present.
|
||||
*/
|
||||
export interface PlaylistRow {
|
||||
/** The playlist's id. */
|
||||
id: string;
|
||||
/** The owner's id. */
|
||||
user_id: string;
|
||||
/** The playlist's title. */
|
||||
title: string;
|
||||
/** The playlist's URL slug. */
|
||||
slug: string | null;
|
||||
/** The owner's description, in Markdown. */
|
||||
description: string | null;
|
||||
/** 1 when anyone may see the playlist, 0 when only its owner does. */
|
||||
is_public: number;
|
||||
/** When the playlist was created. */
|
||||
created_at: string;
|
||||
/** When the playlist was last changed, if ever. */
|
||||
updated_at: string | null;
|
||||
/** The cover image's type, null without one. */
|
||||
image_mime: string | null;
|
||||
/** Any other column the query selects. */
|
||||
[key: string]: SQLOutputValue;
|
||||
}
|
||||
|
||||
/** Checks whether `obj` is a {@link PlaylistRow}. */
|
||||
/** Checks whether a row `node:sqlite` returned is a {@link PlaylistRow}. */
|
||||
export function isPlaylistRow(
|
||||
obj: unknown,
|
||||
): obj is PlaylistRow {
|
||||
@@ -54,7 +45,7 @@ export function isPlaylistRow(
|
||||
(typeof obj.image_mime === "string" || obj.image_mime === null);
|
||||
}
|
||||
|
||||
/** Converts a row to the API's {@link Playlist}. */
|
||||
/** Converts `row` to the {@link Playlist} the playlist routes send. */
|
||||
export function playlistRowToApi(row: PlaylistRow): Playlist {
|
||||
return {
|
||||
id: row.id,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* The `users` table: its row type, its guard, its mappers to and from the
|
||||
* stored {@link User}, and the views of a user the API sends.
|
||||
* 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
|
||||
*/
|
||||
@@ -13,56 +14,65 @@ import {
|
||||
type SelfUser,
|
||||
} from "../../shared/user.ts";
|
||||
|
||||
/** A user as stored: what they see of themselves, plus the password hash. */
|
||||
/**
|
||||
* 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 {
|
||||
/** The scrypt hash of the password, as `hash.salt`. */
|
||||
/** From `hashPassword`, as `hash.salt`. */
|
||||
passwordHash: string;
|
||||
}
|
||||
|
||||
/** Returns what anyone may see of `user`: no hash, no email. */
|
||||
/**
|
||||
* 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: everything but the hash. */
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/** A row of the `users` table. */
|
||||
/**
|
||||
* 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 {
|
||||
/** The user's id. */
|
||||
id: string;
|
||||
/** The user's name, unique. */
|
||||
username: string;
|
||||
/** The scrypt hash of the password, as `hash.salt`. */
|
||||
password_hash: string;
|
||||
/** The user's role. */
|
||||
role: Role;
|
||||
/** When the user registered. */
|
||||
created_at: string;
|
||||
/** When the profile was last changed, if ever. */
|
||||
/** Bumped by any profile or avatar change. */
|
||||
updated_at: string | null;
|
||||
/** The avatar's type, null without one. */
|
||||
/** Null without an avatar. */
|
||||
avatar_mime: string | null;
|
||||
/** The user's profile text, in Markdown. */
|
||||
/** Markdown. */
|
||||
description: string | null;
|
||||
/** The id of the user whose invite they registered with. */
|
||||
/** Null for the seeded admin, who had no inviter. */
|
||||
invited_by: string | null;
|
||||
/**
|
||||
* The inviter's name; present only when the query joins it (`LEFT JOIN users
|
||||
* i ON i.id = u.invited_by`).
|
||||
* 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;
|
||||
/** The user's email address. */
|
||||
email: string;
|
||||
/** Any other column the query selects. */
|
||||
[key: string]: SQLOutputValue;
|
||||
}
|
||||
|
||||
/** Checks whether `obj` is a {@link UserRow}. */
|
||||
/**
|
||||
* 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" &&
|
||||
@@ -82,7 +92,7 @@ export function isUserRow(obj: unknown): obj is UserRow {
|
||||
"email" in obj && typeof obj.email === "string";
|
||||
}
|
||||
|
||||
/** Converts a row to the stored {@link User}. */
|
||||
/** Converts a row to a {@link User}, turning nulls into undefined. */
|
||||
export function userRowToApi(row: UserRow): User {
|
||||
return {
|
||||
id: row.id,
|
||||
@@ -100,7 +110,10 @@ export function userRowToApi(row: UserRow): User {
|
||||
};
|
||||
}
|
||||
|
||||
/** Converts a {@link User} back to a row, without the join-only columns. */
|
||||
/**
|
||||
* 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,
|
||||
|
||||
Reference in New Issue
Block a user