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

@@ -3,9 +3,10 @@ import {
APIException,
type Comment,
} from "../model/interfaces.ts";
import { type SQLOutputValue } from "node:sqlite";
import {
commentRowToApi,
type CommentRow,
commentRowToApi,
db,
isCommentRow,
} from "../model/db.ts";
@@ -18,7 +19,7 @@ function fetchComment(commentId: string): Comment {
const row = db.prepare(
`SELECT ${SELECT_COLS} FROM comments c JOIN users u ON c.user_id = u.id WHERE c.id = ?;`,
).get(commentId);
if (!row || !isCommentRow(row as Record<string, unknown>)) {
if (!row || !isCommentRow(row as Record<string, SQLOutputValue>)) {
throw new APIException(APIErrorCode.NOT_FOUND, 404, "Comment not found");
}
return commentRowToApi(row as CommentRow);
@@ -50,7 +51,14 @@ export function createComment(
const createdAt = new Date();
db.prepare(
`INSERT INTO comments (id, dump_id, user_id, parent_id, body, created_at) VALUES (?, ?, ?, ?, ?, ?);`,
).run(id, dumpId, userId, parentId ?? null, body.trim(), createdAt.toISOString());
).run(
id,
dumpId,
userId,
parentId ?? null,
body.trim(),
createdAt.toISOString(),
);
return fetchComment(id);
}
@@ -73,6 +81,8 @@ export function deleteComment(
"Not authorized to delete this comment",
);
}
db.prepare(`UPDATE comments SET deleted = 1, body = '' WHERE id = ?;`).run(commentId);
db.prepare(`UPDATE comments SET deleted = 1, body = '' WHERE id = ?;`).run(
commentId,
);
return { dumpId: row.dump_id, isPrivate: Boolean(row.is_private) };
}

View File

@@ -12,6 +12,7 @@ import {
broadcastDumpUpdated,
broadcastNewDump,
} from "./ws-service.ts";
import { notifyUserFollowersNewDump } from "./notification-service.ts";
const UPLOADS_DIR = "api/uploads";
const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50 MB
@@ -95,7 +96,10 @@ export async function createUrlDump(
commentCount: 0,
isPrivate,
};
if (!isPrivate) broadcastNewDump(dump);
if (!isPrivate) {
broadcastNewDump(dump);
notifyUserFollowersNewDump(userId, dumpId, title);
}
return dump;
}
@@ -164,7 +168,10 @@ export async function createFileDump(
commentCount: 0,
isPrivate,
};
if (!isPrivate) broadcastNewDump(dump);
if (!isPrivate) {
broadcastNewDump(dump);
notifyUserFollowersNewDump(userId, dumpId, file.name);
}
return dump;
}
@@ -211,7 +218,11 @@ export function listDumps(
).get() as { count: number } | undefined;
if (!rows || !rows.every(isDumpRow)) {
throw new APIException(APIErrorCode.SERVER_ERROR, 500, "Malformed dump data");
throw new APIException(
APIErrorCode.SERVER_ERROR,
500,
"Malformed dump data",
);
}
return { items: rows.map(dumpRowToApi), total: totalRow?.count ?? 0 };
@@ -230,7 +241,9 @@ export async function updateDump(
comment: "comment" in request
? (request.comment ?? undefined)
: dump.comment,
isPrivate: "isPrivate" in request ? (request.isPrivate ?? false) : dump.isPrivate,
isPrivate: "isPrivate" in request
? (request.isPrivate ?? false)
: dump.isPrivate,
};
db.prepare(`UPDATE dumps SET comment = ?, is_private = ? WHERE id = ?;`)
.run(updatedDump.comment ?? null, updatedDump.isPrivate ? 1 : 0, dumpId);
@@ -260,13 +273,22 @@ export async function updateDump(
: dump.comment,
url: newUrl,
richContent,
isPrivate: "isPrivate" in request ? (request.isPrivate ?? false) : dump.isPrivate,
isPrivate: "isPrivate" in request
? (request.isPrivate ?? false)
: dump.isPrivate,
};
const row = dumpApiToRow(updatedDump);
const result = db.prepare(
`UPDATE dumps SET title = ?, comment = ?, url = ?, rich_content = ?, is_private = ? WHERE id = ?;`,
).run(row.title, row.comment, row.url, row.rich_content, row.is_private, row.id);
).run(
row.title,
row.comment,
row.url,
row.rich_content,
row.is_private,
row.id,
);
if (result.changes === 0) {
throw new APIException(APIErrorCode.NOT_FOUND, 404, "Dump not found");
@@ -333,7 +355,11 @@ export function getDumpsByUser(
`SELECT COUNT(*) as count FROM dumps WHERE user_id = ?${privacyFilter};`,
).get(userId) as { count: number } | undefined;
if (!rows.every(isDumpRow)) {
throw new APIException(APIErrorCode.SERVER_ERROR, 500, "Malformed dump data");
throw new APIException(
APIErrorCode.SERVER_ERROR,
500,
"Malformed dump data",
);
}
return { items: rows.map(dumpRowToApi), total: totalRow?.count ?? 0 };
}
@@ -380,7 +406,11 @@ export function getVotedDumpsByUser(
const rows = rawRows as Parameters<typeof isDumpRow>[0][];
if (!rows.every(isDumpRow)) {
throw new APIException(APIErrorCode.SERVER_ERROR, 500, "Malformed dump data");
throw new APIException(
APIErrorCode.SERVER_ERROR,
500,
"Malformed dump data",
);
}
return { items: rows.map(dumpRowToApi), total: totalRow?.count ?? 0 };
}

View File

@@ -0,0 +1,259 @@
import {
APIErrorCode,
APIException,
type Dump,
type FollowStatus,
type Playlist,
} from "../model/interfaces.ts";
import {
notifyPlaylistOwnerNewFollower,
notifyUserNewFollower,
} from "./notification-service.ts";
import {
db,
dumpRowToApi,
isDumpRow,
isFollowRow,
isPlaylistRow,
playlistRowToApi,
} from "../model/db.ts";
// Mirrors dump-service SELECT_COLS_ALIASED — kept local to avoid circular imports
const SELECT_COLS_ALIASED =
"d.id, d.kind, d.title, d.comment, d.user_id, d.created_at, d.url, d.rich_content, " +
"d.file_name, d.file_mime, d.file_size, d.vote_count, d.is_private," +
" (SELECT COUNT(*) FROM comments WHERE dump_id = d.id AND deleted = 0) as comment_count";
// ── Follow / unfollow a user ──────────────────────────────────────────────────
export function followUser(followerId: string, followedUserId: string): void {
if (followerId === followedUserId) {
throw new APIException(
APIErrorCode.BAD_REQUEST,
400,
"Cannot follow yourself",
);
}
let isNew = true;
try {
db.prepare(
`INSERT INTO follows (id, follower_id, followed_user_id, created_at)
VALUES (?, ?, ?, ?);`,
).run(
crypto.randomUUID(),
followerId,
followedUserId,
new Date().toISOString(),
);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
if (msg.toLowerCase().includes("unique")) {
isNew = false;
} else {
throw err;
}
}
if (isNew) notifyUserNewFollower(followerId, followedUserId);
}
export function unfollowUser(followerId: string, followedUserId: string): void {
db.prepare(
`DELETE FROM follows WHERE follower_id = ? AND followed_user_id = ?;`,
).run(followerId, followedUserId);
}
// ── Follow / unfollow a playlist ─────────────────────────────────────────────
export function followPlaylist(followerId: string, playlistId: string): void {
const row = db.prepare(
`SELECT id, is_public FROM playlists WHERE id = ?;`,
).get(playlistId) as { id: string; is_public: number } | undefined;
if (!row) {
throw new APIException(APIErrorCode.NOT_FOUND, 404, "Playlist not found");
}
if (!row.is_public) {
throw new APIException(
APIErrorCode.UNAUTHORIZED,
403,
"Cannot follow a private playlist",
);
}
let isNew = true;
try {
db.prepare(
`INSERT INTO follows (id, follower_id, followed_playlist_id, created_at)
VALUES (?, ?, ?, ?);`,
).run(
crypto.randomUUID(),
followerId,
playlistId,
new Date().toISOString(),
);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
if (msg.toLowerCase().includes("unique")) {
isNew = false;
} else {
throw err;
}
}
if (isNew) notifyPlaylistOwnerNewFollower(followerId, playlistId);
}
export function unfollowPlaylist(followerId: string, playlistId: string): void {
db.prepare(
`DELETE FROM follows WHERE follower_id = ? AND followed_playlist_id = ?;`,
).run(followerId, playlistId);
}
// ── Follow status ─────────────────────────────────────────────────────────────
export function getFollowStatus(followerId: string): FollowStatus {
const rawUserRows = db.prepare(
`SELECT id, follower_id, followed_user_id, followed_playlist_id, created_at
FROM follows WHERE follower_id = ? AND followed_user_id IS NOT NULL;`,
).all(followerId) as Parameters<typeof isFollowRow>[0][];
const rawPlaylistRows = db.prepare(
`SELECT id, follower_id, followed_user_id, followed_playlist_id, created_at
FROM follows WHERE follower_id = ? AND followed_playlist_id IS NOT NULL;`,
).all(followerId) as Parameters<typeof isFollowRow>[0][];
if (!rawUserRows.every(isFollowRow) || !rawPlaylistRows.every(isFollowRow)) {
throw new APIException(
APIErrorCode.SERVER_ERROR,
500,
"Malformed follow data",
);
}
return {
followedUserIds: rawUserRows.map((r) => r.followed_user_id!),
followedPlaylistIds: rawPlaylistRows.map((r) => r.followed_playlist_id!),
};
}
// ── Followed-users feed ───────────────────────────────────────────────────────
export function getFollowedUsersDumpFeed(
followerId: string,
page: number,
limit: number,
): { items: Dump[]; total: number } {
const offset = (page - 1) * limit;
const rawRows = db.prepare(
`SELECT ${SELECT_COLS_ALIASED}
FROM dumps d
INNER JOIN follows f ON f.followed_user_id = d.user_id
WHERE f.follower_id = ?
AND d.is_private = 0
ORDER BY d.created_at DESC
LIMIT ? OFFSET ?;`,
).all(followerId, limit, offset);
const totalRow = db.prepare(
`SELECT COUNT(*) as count
FROM dumps d
INNER JOIN follows f ON f.followed_user_id = d.user_id
WHERE f.follower_id = ?
AND d.is_private = 0;`,
).get(followerId) as { count: number } | undefined;
const userFeedRows = rawRows as Parameters<typeof isDumpRow>[0][];
if (!userFeedRows.every(isDumpRow)) {
throw new APIException(
APIErrorCode.SERVER_ERROR,
500,
"Malformed dump data",
);
}
return { items: userFeedRows.map(dumpRowToApi), total: totalRow?.count ?? 0 };
}
// ── Followed-playlists dump feed ──────────────────────────────────────────────
export function getFollowedPlaylistsDumpFeed(
followerId: string,
page: number,
limit: number,
): { items: Dump[]; total: number } {
const offset = (page - 1) * limit;
const rawRows = db.prepare(
`SELECT ${SELECT_COLS_ALIASED}
FROM dumps d
INNER JOIN playlist_dumps pd ON pd.dump_id = d.id
INNER JOIN playlists p ON p.id = pd.playlist_id
INNER JOIN follows f ON f.followed_playlist_id = p.id
WHERE f.follower_id = ?
AND p.is_public = 1
AND d.is_private = 0
GROUP BY d.id
ORDER BY MAX(pd.added_at) DESC
LIMIT ? OFFSET ?;`,
).all(followerId, limit, offset);
const totalRow = db.prepare(
`SELECT COUNT(DISTINCT d.id) as count
FROM dumps d
INNER JOIN playlist_dumps pd ON pd.dump_id = d.id
INNER JOIN playlists p ON p.id = pd.playlist_id
INNER JOIN follows f ON f.followed_playlist_id = p.id
WHERE f.follower_id = ?
AND p.is_public = 1
AND d.is_private = 0;`,
).get(followerId) as { count: number } | undefined;
const playlistFeedRows = rawRows as Parameters<typeof isDumpRow>[0][];
if (!playlistFeedRows.every(isDumpRow)) {
throw new APIException(
APIErrorCode.SERVER_ERROR,
500,
"Malformed dump data",
);
}
return {
items: playlistFeedRows.map(dumpRowToApi),
total: totalRow?.count ?? 0,
};
}
// ── Followed playlists (as playlist objects) ──────────────────────────────────
export function getFollowedPlaylistsByUser(
userId: string,
page: number,
limit: number,
): { items: Playlist[]; total: number } {
const offset = (page - 1) * limit;
const totalRow = db.prepare(
`SELECT COUNT(*) as count
FROM follows f
WHERE f.follower_id = ? AND f.followed_playlist_id IS NOT NULL;`,
).get(userId) as { count: number } | undefined;
const rawRows = db.prepare(
`SELECT p.*, u.username as owner_username,
(SELECT COUNT(*) FROM playlist_dumps pd WHERE pd.playlist_id = p.id) as dump_count
FROM playlists p
LEFT JOIN users u ON u.id = p.user_id
INNER JOIN follows f ON f.followed_playlist_id = p.id
WHERE f.follower_id = ?
AND p.is_public = 1
ORDER BY f.created_at DESC
LIMIT ? OFFSET ?;`,
).all(userId, limit, offset) as Parameters<typeof isPlaylistRow>[0][];
if (!rawRows.every(isPlaylistRow)) {
throw new APIException(
APIErrorCode.SERVER_ERROR,
500,
"Malformed playlist data",
);
}
return { items: rawRows.map(playlistRowToApi), total: totalRow?.count ?? 0 };
}

View File

@@ -0,0 +1,53 @@
import { APIErrorCode, APIException } from "../model/interfaces.ts";
import { db, isInviteRow } from "../model/db.ts";
import { createInviteToken, verifyInviteToken } from "../lib/jwt.ts";
export async function createInvite(inviterId: string): Promise<string> {
const token = await createInviteToken(inviterId);
db.prepare(
`INSERT INTO invites (token, inviter_id, created_at) VALUES (?, ?, ?);`,
).run(token, inviterId, new Date().toISOString());
return token;
}
/**
* Verifies the JWT signature + expiry and checks the token exists and has not
* been used. Returns the inviterId on success; throws APIException otherwise.
*/
export async function validateInvite(token: string): Promise<string> {
const payload = await verifyInviteToken(token);
if (!payload) {
throw new APIException(
APIErrorCode.NOT_FOUND,
404,
"Invalid or expired invite",
);
}
const row = db.prepare(
`SELECT token, inviter_id, used_at, created_at FROM invites WHERE token = ?;`,
).get(token);
if (!row || !isInviteRow(row)) {
throw new APIException(APIErrorCode.NOT_FOUND, 404, "Invite not found");
}
if (row.used_at !== null) {
throw new APIException(
APIErrorCode.VALIDATION_ERROR,
409,
"Invite already used",
);
}
return payload.inviterId;
}
/**
* Marks the token as used. Call this only after the user has been created.
*/
export function redeemInvite(token: string): void {
db.prepare(
`UPDATE invites SET used_at = ? WHERE token = ?;`,
).run(new Date().toISOString(), token);
}

View File

@@ -0,0 +1,212 @@
import type {
Notification,
NotificationData,
NotificationType,
} from "../model/interfaces.ts";
import { APIErrorCode, APIException } from "../model/interfaces.ts";
import { db, isNotificationRow, notificationRowToApi } from "../model/db.ts";
import { sendToUser } from "./ws-service.ts";
// ── Core CRUD ─────────────────────────────────────────────────────────────────
// sourceKey: if set, INSERT OR IGNORE — same (user_id, source_key) pair is a no-op.
function createNotification(
userId: string,
type: NotificationType,
data: NotificationData,
sourceKey: string | null = null,
): void {
const id = crypto.randomUUID();
const createdAt = new Date().toISOString();
const dataJson = JSON.stringify(data);
let changes: number;
if (sourceKey) {
// INSERT OR IGNORE: idempotent — same (user_id, source_key) pair is a no-op
const result = db.prepare(
`INSERT OR IGNORE INTO notifications (id, user_id, type, data, read, created_at, source_key)
VALUES (?, ?, ?, ?, 0, ?, ?);`,
).run(id, userId, type, dataJson, createdAt, sourceKey);
changes = result.changes as number;
} else {
const result = db.prepare(
`INSERT INTO notifications (id, user_id, type, data, read, created_at, source_key)
VALUES (?, ?, ?, ?, 0, ?, NULL);`,
).run(id, userId, type, dataJson, createdAt);
changes = result.changes as number;
}
if (changes > 0) {
sendToUser(userId, {
type: "notification_created",
notification: { id, userId, type, data, read: false, createdAt },
});
}
}
export function getNotificationsForUser(
userId: string,
page: number,
limit: number,
): { items: Notification[]; total: number } {
const offset = (page - 1) * limit;
const rawRows = db.prepare(
`SELECT * FROM notifications WHERE user_id = ? ORDER BY created_at DESC LIMIT ? OFFSET ?;`,
).all(userId, limit, offset) as Parameters<typeof isNotificationRow>[0][];
const totalRow = db.prepare(
`SELECT COUNT(*) as count FROM notifications WHERE user_id = ?;`,
).get(userId) as { count: number } | undefined;
if (!rawRows.every(isNotificationRow)) {
throw new APIException(
APIErrorCode.SERVER_ERROR,
500,
"Malformed notification data",
);
}
return {
items: rawRows.map(notificationRowToApi),
total: totalRow?.count ?? 0,
};
}
export function getUnreadCount(userId: string): number {
const row = db.prepare(
`SELECT COUNT(*) as count FROM notifications WHERE user_id = ? AND read = 0;`,
).get(userId) as { count: number } | undefined;
return row?.count ?? 0;
}
export function markAllRead(userId: string): void {
db.prepare(`UPDATE notifications SET read = 1 WHERE user_id = ?;`).run(
userId,
);
}
export function markOneRead(notificationId: string, userId: string): void {
db.prepare(
`UPDATE notifications SET read = 1 WHERE id = ? AND user_id = ?;`,
).run(notificationId, userId);
}
// ── Trigger helpers ───────────────────────────────────────────────────────────
export function notifyUserNewFollower(
followerId: string,
followedUserId: string,
): void {
const followerRow = db.prepare(
`SELECT username FROM users WHERE id = ?;`,
).get(followerId) as { username: string } | undefined;
if (!followerRow) return;
createNotification(
followedUserId,
"user_followed",
{ followerId, followerUsername: followerRow.username },
`user-followed:${followedUserId}:${followerId}`,
);
}
export function notifyPlaylistOwnerNewFollower(
followerId: string,
playlistId: string,
): void {
const followerRow = db.prepare(
`SELECT username FROM users WHERE id = ?;`,
).get(followerId) as { username: string } | undefined;
const playlistRow = db.prepare(
`SELECT title, user_id FROM playlists WHERE id = ?;`,
).get(playlistId) as { title: string; user_id: string } | undefined;
if (!followerRow || !playlistRow) return;
if (followerId === playlistRow.user_id) return;
createNotification(
playlistRow.user_id,
"playlist_followed",
{
followerId,
followerUsername: followerRow.username,
playlistId,
playlistTitle: playlistRow.title,
},
`followed:${playlistId}:${followerId}`,
);
}
export function notifyUserFollowersNewDump(
dumperId: string,
dumpId: string,
dumpTitle: string,
): void {
const posterRow = db.prepare(
`SELECT username FROM users WHERE id = ?;`,
).get(dumperId) as { username: string } | undefined;
if (!posterRow) return;
const followerRows = db.prepare(
`SELECT follower_id FROM follows WHERE followed_user_id = ?;`,
).all(dumperId) as { follower_id: string }[];
for (const row of followerRows) {
createNotification(
row.follower_id,
"user_dump_posted",
{ dumperId, dumperUsername: posterRow.username, dumpId, dumpTitle },
`dump:${dumpId}`,
);
}
}
export function notifyDumpOwnerUpvote(
voterId: string,
dumpId: string,
): void {
const voterRow = db.prepare(
`SELECT username FROM users WHERE id = ?;`,
).get(voterId) as { username: string } | undefined;
const dumpRow = db.prepare(
`SELECT title, user_id FROM dumps WHERE id = ?;`,
).get(dumpId) as { title: string; user_id: string } | undefined;
if (!voterRow || !dumpRow) return;
if (voterId === dumpRow.user_id) return; // no self-notification
createNotification(
dumpRow.user_id,
"dump_upvoted",
{
voterId,
voterUsername: voterRow.username,
dumpId,
dumpTitle: dumpRow.title,
},
`upvote:${dumpId}:${voterId}`,
);
}
export function notifyPlaylistFollowersNewDump(
playlistId: string,
playlistTitle: string,
dumpId: string,
dumpTitle: string,
): void {
const followerRows = db.prepare(
`SELECT follower_id FROM follows WHERE followed_playlist_id = ?;`,
).all(playlistId) as { follower_id: string }[];
for (const row of followerRows) {
createNotification(
row.follower_id,
"playlist_dump_added",
{ dumpId, dumpTitle, playlistId, playlistTitle },
`pdump:${playlistId}:${dumpId}`,
);
}
}

View File

@@ -22,14 +22,19 @@ import {
broadcastPlaylistDumpsUpdated,
broadcastPlaylistUpdated,
} from "./ws-service.ts";
import { notifyPlaylistFollowersNewDump } from "./notification-service.ts";
const DUMP_SELECT_COLS =
"id, kind, title, comment, user_id, created_at, url, rich_content, file_name, file_mime, file_size, vote_count, is_private";
const PLAYLIST_SELECT = `p.*, u.username as owner_username,
(SELECT COUNT(*) FROM playlist_dumps pd WHERE pd.playlist_id = p.id) as dump_count
FROM playlists p LEFT JOIN users u ON u.id = p.user_id`;
function getPlaylistById(playlistId: string): Playlist {
const row = db.prepare(`SELECT * FROM playlists WHERE id = ?;`).get(
playlistId,
);
const row = db.prepare(
`SELECT ${PLAYLIST_SELECT} WHERE p.id = ?;`,
).get(playlistId);
if (!row || !isPlaylistRow(row)) {
throw new APIException(APIErrorCode.NOT_FOUND, 404, "Playlist not found");
}
@@ -90,9 +95,7 @@ export function getPlaylist(
const dumps: Dump[] = rows.filter(isDumpRow).map(dumpRowToApi);
// Owners always see their own private dumps; strip them for non-owners regardless
const visibleDumps = isOwner
? dumps
: dumps.filter((d) => !d.isPrivate);
const visibleDumps = isOwner ? dumps : dumps.filter((d) => !d.isPrivate);
return { ...playlist, dumps: visibleDumps };
}
@@ -110,10 +113,8 @@ export function listPlaylistsByUser(
? `SELECT COUNT(*) as count FROM playlists WHERE user_id = ?;`
: `SELECT COUNT(*) as count FROM playlists WHERE user_id = ? AND is_public = 1;`;
const sql = isOwner
? `SELECT p.*, (SELECT COUNT(*) FROM playlist_dumps pd WHERE pd.playlist_id = p.id) as dump_count
FROM playlists p WHERE p.user_id = ? ORDER BY p.created_at DESC LIMIT ? OFFSET ?;`
: `SELECT p.*, (SELECT COUNT(*) FROM playlist_dumps pd WHERE pd.playlist_id = p.id) as dump_count
FROM playlists p WHERE p.user_id = ? AND p.is_public = 1 ORDER BY p.created_at DESC LIMIT ? OFFSET ?;`;
? `SELECT ${PLAYLIST_SELECT} WHERE p.user_id = ? ORDER BY p.created_at DESC LIMIT ? OFFSET ?;`
: `SELECT ${PLAYLIST_SELECT} WHERE p.user_id = ? AND p.is_public = 1 ORDER BY p.created_at DESC LIMIT ? OFFSET ?;`;
const totalRow = db.prepare(countSql).get(userId) as
| { count: number }
@@ -227,6 +228,20 @@ export function addDumpToPlaylist(
const dumpIds = getCurrentDumpIds(playlistId);
broadcastPlaylistDumpsUpdated(playlist, dumpIds);
if (playlist.isPublic) {
const dumpRow = db.prepare(`SELECT title FROM dumps WHERE id = ?;`).get(
dumpId,
) as { title: string } | undefined;
if (dumpRow) {
notifyPlaylistFollowersNewDump(
playlistId,
playlist.title,
dumpId,
dumpRow.title,
);
}
}
}
export function removeDumpFromPlaylist(

View File

@@ -30,7 +30,9 @@ export const soundcloudProvider: RichContentProvider = {
title: extractOgTag(html, "title"),
description: extractOgTag(html, "description"),
thumbnailUrl: extractOgTag(html, "image"),
embedUrl: `https://w.soundcloud.com/player/?url=${encodeURIComponent(url)}&visual=true&auto_play=false`,
embedUrl: `https://w.soundcloud.com/player/?url=${
encodeURIComponent(url)
}&visual=true&auto_play=false`,
};
},
};

View File

@@ -12,7 +12,9 @@ function extractVideoId(url: string): string | null {
if (u.pathname === "/watch" || u.pathname.startsWith("/watch?")) {
return u.searchParams.get("v");
}
if (u.pathname.startsWith("/embed/") || u.pathname.startsWith("/shorts/")) {
if (
u.pathname.startsWith("/embed/") || u.pathname.startsWith("/shorts/")
) {
return u.pathname.split("/")[2] || null;
}
}

View File

@@ -9,8 +9,15 @@ import { db, isUserRow, userApiToRow, userRowToApi } from "../model/db.ts";
import { hashPassword } from "../lib/jwt.ts";
const USER_SELECT =
`SELECT u.id, u.username, u.password_hash, u.is_admin, u.created_at, u.avatar_mime, u.invited_by,
i.username as invited_by_username
FROM users u
LEFT JOIN users i ON i.id = u.invited_by`;
export async function createUser(
request: RegisterUserRequest,
inviterId: string | null,
): Promise<User> {
const userId = crypto.randomUUID();
const createdAt = new Date();
@@ -30,14 +37,15 @@ export async function createUser(
const passwordHash = await hashPassword(request.password);
db.prepare(
`INSERT INTO users (id, username, password_hash, is_admin, created_at)
VALUES (?, ?, ?, ?, ?);`,
`INSERT INTO users (id, username, password_hash, is_admin, created_at, invited_by)
VALUES (?, ?, ?, ?, ?, ?);`,
).run(
userId,
request.username,
passwordHash,
0,
createdAt.toISOString(),
inviterId,
);
return {
@@ -51,8 +59,7 @@ export async function createUser(
export function getUserById(userId: string): User {
const userRow = db.prepare(
`SELECT id, username, password_hash, is_admin, created_at, avatar_mime
FROM users WHERE id = ?`,
`${USER_SELECT} WHERE u.id = ?`,
).get(userId);
if (!userRow || !isUserRow(userRow)) {
@@ -64,8 +71,7 @@ export function getUserById(userId: string): User {
export function getUserByUsername(username: string): User {
const userRow = db.prepare(
`SELECT id, username, password_hash, is_admin, created_at, avatar_mime
FROM users WHERE username = ?`,
`${USER_SELECT} WHERE u.username = ?`,
).get(username);
if (!userRow || !isUserRow(userRow)) {
@@ -77,7 +83,7 @@ export function getUserByUsername(username: string): User {
export function listUsers(): User[] {
const userRows = db.prepare(
`SELECT id, username, password_hash, is_admin, created_at, avatar_mime FROM users`,
`${USER_SELECT}`,
).all();
if (!userRows || !userRows.every(isUserRow)) {

View File

@@ -1,5 +1,6 @@
import { APIErrorCode, APIException } from "../model/interfaces.ts";
import { db } from "../model/db.ts";
import { notifyDumpOwnerUpvote } from "./notification-service.ts";
export function castVote(dumpId: string, userId: string): number {
try {
@@ -14,6 +15,7 @@ export function castVote(dumpId: string, userId: string): number {
`SELECT vote_count FROM dumps WHERE id = ?;`,
).get(dumpId) as { vote_count: number } | undefined;
db.exec("COMMIT;");
notifyDumpOwnerUpvote(userId, dumpId);
return row?.vote_count ?? 0;
} catch (err) {
db.exec("ROLLBACK;");

View File

@@ -1,4 +1,9 @@
import type { Comment, Dump, OnlineUser, Playlist } from "../model/interfaces.ts";
import type {
Comment,
Dump,
OnlineUser,
Playlist,
} from "../model/interfaces.ts";
export interface WsClient {
socket: WebSocket;
@@ -46,6 +51,14 @@ function send(socket: WebSocket, data: unknown): void {
}
}
export function sendToUser(userId: string, data: unknown): void {
for (const client of clients) {
if (client.userId === userId) {
send(client.socket, data);
}
}
}
export function broadcastPresence(): void {
const users = getOnlineUsers();
for (const client of clients) {
@@ -136,7 +149,10 @@ export function broadcastCommentCreated(comment: Comment): void {
}
}
export function broadcastCommentDeleted(commentId: string, dumpId: string): void {
export function broadcastCommentDeleted(
commentId: string,
dumpId: string,
): void {
for (const client of clients) {
send(client.socket, { type: "comment_deleted", commentId, dumpId });
}