v3: follows, notifications, invite-only registration, unread markers
This commit is contained in:
132
api/model/db.ts
132
api/model/db.ts
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user