v3: follows, notifications, invite-only registration, unread markers

This commit is contained in:
khannurien
2026-03-21 18:42:47 +00:00
parent 7c098e7c4c
commit 608c6bc6a8
55 changed files with 4743 additions and 884 deletions

View File

@@ -2,6 +2,8 @@ import { DatabaseSync, type SQLOutputValue } from "node:sqlite";
import {
type Comment,
Dump,
type Notification,
type NotificationType,
type Playlist,
type RichContent,
type User,
@@ -10,31 +12,10 @@ import {
export const db = new DatabaseSync("api/sql/gerbeur.db");
db.exec("PRAGMA foreign_keys = ON;");
// Migration: add is_private column if it doesn't exist yet
try {
db.exec(`ALTER TABLE dumps ADD COLUMN is_private INTEGER NOT NULL DEFAULT 0;`);
} catch { /* column already exists */ }
// Migration: create comments table if it doesn't exist yet
try {
db.exec(`CREATE TABLE IF NOT EXISTS comments (
id TEXT PRIMARY KEY,
dump_id TEXT NOT NULL REFERENCES dumps(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
parent_id TEXT REFERENCES comments(id) ON DELETE CASCADE,
body TEXT NOT NULL,
created_at TEXT NOT NULL,
deleted INTEGER NOT NULL DEFAULT 0
);`);
db.exec(`CREATE INDEX IF NOT EXISTS idx_comments_dump ON comments(dump_id, created_at);`);
db.exec(`CREATE INDEX IF NOT EXISTS idx_votes_user ON votes(user_id);`);
db.exec(`CREATE INDEX IF NOT EXISTS idx_playlist_dumps_dump ON playlist_dumps(dump_id);`);
} catch { /* already exists */ }
// Migration: add deleted column to comments if it doesn't exist yet
try {
db.exec(`ALTER TABLE comments ADD COLUMN deleted INTEGER NOT NULL DEFAULT 0;`);
} catch { /* column already exists */ }
// Purge expired unused invites on startup
db.prepare(
`DELETE FROM invites WHERE used_at IS NULL AND created_at < datetime('now', '-7 days');`,
).run();
/**
* Database Row Types
@@ -53,7 +34,7 @@ export interface DumpRow {
file_mime: string | null;
file_size: number | null;
vote_count: number;
comment_count?: number;
comment_count: number;
is_private: number;
[key: string]: SQLOutputValue; // Index signature
}
@@ -65,6 +46,9 @@ export interface UserRow {
is_admin: number;
created_at: string;
avatar_mime: string | null;
invited_by: string | null;
// Present only when joined: LEFT JOIN users i ON i.id = u.invited_by
invited_by_username: string | null;
[key: string]: SQLOutputValue; // Index signature
}
@@ -127,7 +111,7 @@ export function dumpRowToApi(row: DumpRow): Dump {
fileMime: row.file_mime ?? undefined,
fileSize: row.file_size ?? undefined,
voteCount: row.vote_count,
commentCount: row.comment_count ?? 0,
commentCount: row.comment_count,
isPrivate: Boolean(row.is_private),
};
}
@@ -146,6 +130,7 @@ export function dumpApiToRow(dump: Dump): DumpRow {
file_mime: dump.fileMime ?? null,
file_size: dump.fileSize ?? null,
vote_count: dump.voteCount,
comment_count: dump.commentCount,
is_private: dump.isPrivate ? 1 : 0,
};
}
@@ -158,6 +143,9 @@ export function userRowToApi(row: UserRow): User {
isAdmin: Boolean(row.is_admin),
createdAt: new Date(row.created_at),
avatarMime: row.avatar_mime ?? undefined,
invitedByUsername: typeof row.invited_by_username === "string"
? row.invited_by_username
: undefined,
};
}
@@ -169,6 +157,8 @@ export function userApiToRow(user: User): UserRow {
is_admin: user.isAdmin ? 1 : 0,
created_at: user.createdAt.toISOString(),
avatar_mime: user.avatarMime ?? null,
invited_by: null,
invited_by_username: null,
};
}
@@ -185,7 +175,9 @@ export interface CommentRow {
[key: string]: SQLOutputValue;
}
export function isCommentRow(obj: Record<string, SQLOutputValue>): obj is CommentRow {
export function isCommentRow(
obj: Record<string, SQLOutputValue>,
): obj is CommentRow {
return !!obj && typeof obj === "object" &&
typeof obj.id === "string" &&
typeof obj.dump_id === "string" &&
@@ -195,7 +187,8 @@ export function isCommentRow(obj: Record<string, SQLOutputValue>): obj is Commen
typeof obj.created_at === "string" &&
typeof obj.deleted === "number" &&
typeof obj.author_username === "string" &&
(typeof obj.author_avatar_mime === "string" || obj.author_avatar_mime === null);
(typeof obj.author_avatar_mime === "string" ||
obj.author_avatar_mime === null);
}
export function commentRowToApi(row: CommentRow): Comment {
@@ -243,5 +236,86 @@ export function playlistRowToApi(row: PlaylistRow): Playlist {
createdAt: new Date(row.created_at),
imageMime: row.image_mime ?? undefined,
dumpCount: typeof row.dump_count === "number" ? row.dump_count : undefined,
ownerUsername: typeof row.owner_username === "string"
? row.owner_username
: undefined,
};
}
export interface FollowRow {
id: string;
follower_id: string;
followed_user_id: string | null;
followed_playlist_id: string | null;
created_at: string;
[key: string]: SQLOutputValue;
}
export function isFollowRow(
obj: Record<string, SQLOutputValue>,
): obj is FollowRow {
return !!obj &&
typeof obj.id === "string" &&
typeof obj.follower_id === "string" &&
typeof obj.created_at === "string" &&
(obj.followed_user_id === null ||
typeof obj.followed_user_id === "string") &&
(obj.followed_playlist_id === null ||
typeof obj.followed_playlist_id === "string");
}
// ── Notifications ─────────────────────────────────────────────────────────────
export interface NotificationRow {
id: string;
user_id: string;
type: string;
data: string;
read: number;
created_at: string;
source_key: string | null;
[key: string]: SQLOutputValue;
}
export function isNotificationRow(
obj: Record<string, SQLOutputValue>,
): obj is NotificationRow {
return !!obj && typeof obj === "object" &&
typeof obj.id === "string" &&
typeof obj.user_id === "string" &&
typeof obj.type === "string" &&
typeof obj.data === "string" &&
typeof obj.read === "number" &&
typeof obj.created_at === "string";
}
export function notificationRowToApi(row: NotificationRow): Notification {
return {
id: row.id,
userId: row.user_id,
type: row.type as NotificationType,
data: JSON.parse(row.data),
read: Boolean(row.read),
createdAt: new Date(row.created_at),
};
}
// ── Invites ───────────────────────────────────────────────────────────────────
export interface InviteRow {
token: string;
inviter_id: string;
used_at: string | null;
created_at: string;
[key: string]: SQLOutputValue;
}
export function isInviteRow(
obj: Record<string, SQLOutputValue>,
): obj is InviteRow {
return !!obj && typeof obj === "object" &&
typeof obj.token === "string" &&
typeof obj.inviter_id === "string" &&
typeof obj.created_at === "string" &&
(obj.used_at === null || typeof obj.used_at === "string");
}

View File

@@ -41,6 +41,7 @@ export interface User {
isAdmin: boolean;
createdAt: Date;
avatarMime?: string;
invitedByUsername?: string;
}
export interface LoginUserRequest {
@@ -51,6 +52,7 @@ export interface LoginUserRequest {
export interface RegisterUserRequest {
username: string;
password: string;
inviteToken: string;
}
export interface UpdateUserRequest {
@@ -70,7 +72,8 @@ export function isRegisterUserRequest(
): obj is RegisterUserRequest {
return !!obj && typeof obj === "object" &&
"username" in obj && typeof obj.username === "string" &&
"password" in obj && typeof obj.password === "string";
"password" in obj && typeof obj.password === "string" &&
"inviteToken" in obj && typeof obj.inviteToken === "string";
}
export function isUpdateUserRequest(obj: unknown): obj is UpdateUserRequest {
@@ -101,6 +104,19 @@ export function isAuthPayload(obj: unknown): obj is AuthPayload {
"exp" in obj && typeof obj.exp === "number";
}
export interface InvitePayload {
purpose: "invite";
inviterId: string;
exp: number;
}
export function isInvitePayload(obj: unknown): obj is InvitePayload {
return !!obj && typeof obj === "object" &&
"purpose" in obj && (obj as Record<string, unknown>).purpose === "invite" &&
"inviterId" in obj &&
typeof (obj as Record<string, unknown>).inviterId === "string";
}
/**
* API
*/
@@ -171,11 +187,14 @@ export interface CreateCommentRequest {
parentId?: string;
}
export function isCreateCommentRequest(obj: unknown): obj is CreateCommentRequest {
export function isCreateCommentRequest(
obj: unknown,
): obj is CreateCommentRequest {
if (!obj || typeof obj !== "object") return false;
const o = obj as Record<string, unknown>;
return typeof o.body === "string" && (o.body as string).trim().length > 0 &&
(!("parentId" in o) || typeof o.parentId === "string" || o.parentId === null);
(!("parentId" in o) || typeof o.parentId === "string" ||
o.parentId === null);
}
/**
@@ -191,6 +210,7 @@ export interface Playlist {
createdAt: Date;
imageMime?: string;
dumpCount?: number;
ownerUsername?: string;
}
export interface PlaylistWithDumps extends Playlist {
@@ -345,3 +365,72 @@ export interface PingMessage {
export interface PongMessage {
type: "pong";
}
/**
* Follows
*/
export interface FollowStatus {
followedUserIds: string[];
followedPlaylistIds: string[];
}
/**
* Notifications
*/
export type NotificationType =
| "playlist_followed"
| "user_followed"
| "user_dump_posted"
| "playlist_dump_added"
| "dump_upvoted";
export interface PlaylistFollowedData {
followerId: string;
followerUsername: string;
playlistId: string;
playlistTitle: string;
}
export interface UserFollowedData {
followerId: string;
followerUsername: string;
}
export interface UserDumpPostedData {
dumperId: string;
dumperUsername: string;
dumpId: string;
dumpTitle: string;
}
export interface PlaylistDumpAddedData {
dumpId: string;
dumpTitle: string;
playlistId: string;
playlistTitle: string;
}
export interface DumpUpvotedData {
voterId: string;
voterUsername: string;
dumpId: string;
dumpTitle: string;
}
export type NotificationData =
| PlaylistFollowedData
| UserFollowedData
| UserDumpPostedData
| PlaylistDumpAddedData
| DumpUpvotedData;
export interface Notification {
id: string;
userId: string;
type: NotificationType;
data: NotificationData;
read: boolean;
createdAt: Date;
}