v3: follows, notifications, invite-only registration, unread markers
This commit is contained in:
@@ -1,11 +1,38 @@
|
||||
import { randomBytes, scrypt } from "node:crypto";
|
||||
import { jwtVerify, SignJWT } from "@panva/jose";
|
||||
|
||||
import { type AuthPayload, isAuthPayload } from "../model/interfaces.ts";
|
||||
import {
|
||||
type AuthPayload,
|
||||
InvitePayload,
|
||||
isAuthPayload,
|
||||
isInvitePayload,
|
||||
} from "../model/interfaces.ts";
|
||||
|
||||
const JWT_SECRET = "FIXME-gerbeur-dev-env";
|
||||
const JWT_KEY = new TextEncoder().encode(JWT_SECRET);
|
||||
|
||||
// ── Invite tokens ─────────────────────────────────────────────────────────────
|
||||
|
||||
export async function createInviteToken(inviterId: string): Promise<string> {
|
||||
return await new SignJWT({ purpose: "invite", inviterId })
|
||||
.setProtectedHeader({ alg: "HS256" })
|
||||
.setJti(crypto.randomUUID())
|
||||
.setExpirationTime("7d")
|
||||
.sign(JWT_KEY);
|
||||
}
|
||||
|
||||
export async function verifyInviteToken(
|
||||
token: string,
|
||||
): Promise<InvitePayload | null> {
|
||||
try {
|
||||
const { payload } = await jwtVerify(token, JWT_KEY);
|
||||
if (!isInvitePayload(payload)) return null;
|
||||
return payload as InvitePayload;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function createJWT(
|
||||
payload: Omit<AuthPayload, "exp">,
|
||||
): Promise<string> {
|
||||
|
||||
15
api/main.ts
15
api/main.ts
@@ -9,6 +9,9 @@ import wsRouter from "./routes/ws.ts";
|
||||
import previewRouter from "./routes/preview.ts";
|
||||
import playlistsRouter from "./routes/playlists.ts";
|
||||
import commentsRouter from "./routes/comments.ts";
|
||||
import followsRouter from "./routes/follows.ts";
|
||||
import notificationsRouter from "./routes/notifications.ts";
|
||||
import invitesRouter from "./routes/invites.ts";
|
||||
|
||||
import { BASE_URL, HOSTNAME, PORT } from "./config.ts";
|
||||
import { errorMiddleware } from "./middleware/error.ts";
|
||||
@@ -50,6 +53,18 @@ app.use(
|
||||
commentsRouter.routes(),
|
||||
commentsRouter.allowedMethods(),
|
||||
);
|
||||
app.use(
|
||||
followsRouter.routes(),
|
||||
followsRouter.allowedMethods(),
|
||||
);
|
||||
app.use(
|
||||
notificationsRouter.routes(),
|
||||
notificationsRouter.allowedMethods(),
|
||||
);
|
||||
app.use(
|
||||
invitesRouter.routes(),
|
||||
invitesRouter.allowedMethods(),
|
||||
);
|
||||
app.use(routeStaticFilesFrom([
|
||||
`${Deno.cwd()}/dist`,
|
||||
`${Deno.cwd()}/public`,
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -31,14 +31,16 @@ router.get("/dumps/:dumpId/comments", async (ctx) => {
|
||||
}
|
||||
const dump = getDump(ctx.params.dumpId, requestingUserId);
|
||||
const comments = getComments(dump.id);
|
||||
const responseBody: APIResponse<Comment[]> = { success: true, data: comments };
|
||||
const responseBody: APIResponse<Comment[]> = {
|
||||
success: true,
|
||||
data: comments,
|
||||
};
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
// POST /api/dumps/:dumpId/comments — auth required
|
||||
router.post("/dumps/:dumpId/comments", authMiddleware, async (ctx) => {
|
||||
const userId = ctx.state.user.userId as string;
|
||||
const isAdmin = (ctx.state.user.isAdmin ?? false) as boolean;
|
||||
const dump = getDump(ctx.params.dumpId, userId);
|
||||
const body = await ctx.request.body.json();
|
||||
if (!isCreateCommentRequest(body)) {
|
||||
|
||||
@@ -93,8 +93,17 @@ router.get("/", async (ctx) => {
|
||||
const payload = await verifyJWT(authHeader.substring(7));
|
||||
if (payload) requestingUserId = payload.userId;
|
||||
}
|
||||
const page = Math.max(1, parseInt(ctx.request.url.searchParams.get("page") ?? "1") || 1);
|
||||
const limit = Math.min(Math.max(1, parseInt(ctx.request.url.searchParams.get("limit") ?? "20") || 20), 100);
|
||||
const page = Math.max(
|
||||
1,
|
||||
parseInt(ctx.request.url.searchParams.get("page") ?? "1") || 1,
|
||||
);
|
||||
const limit = Math.min(
|
||||
Math.max(
|
||||
1,
|
||||
parseInt(ctx.request.url.searchParams.get("limit") ?? "20") || 20,
|
||||
),
|
||||
100,
|
||||
);
|
||||
const { items, total } = listDumps(page, limit, requestingUserId);
|
||||
const responseBody: APIResponse<PaginatedData<Dump>> = {
|
||||
success: true,
|
||||
|
||||
108
api/routes/follows.ts
Normal file
108
api/routes/follows.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { Router } from "@oak/oak";
|
||||
import { authMiddleware } from "../middleware/auth.ts";
|
||||
import {
|
||||
type APIResponse,
|
||||
type Dump,
|
||||
type FollowStatus,
|
||||
type PaginatedData,
|
||||
} from "../model/interfaces.ts";
|
||||
import {
|
||||
followPlaylist,
|
||||
followUser,
|
||||
getFollowedPlaylistsDumpFeed,
|
||||
getFollowedUsersDumpFeed,
|
||||
getFollowStatus,
|
||||
unfollowPlaylist,
|
||||
unfollowUser,
|
||||
} from "../services/follow-service.ts";
|
||||
|
||||
const router = new Router({ prefix: "/api/follows" });
|
||||
|
||||
// Static routes first to prevent Oak matching "status"/"feed" as a :param
|
||||
|
||||
// GET /api/follows/status
|
||||
router.get("/status", authMiddleware, (ctx) => {
|
||||
const status = getFollowStatus(ctx.state.user.userId as string);
|
||||
const body: APIResponse<FollowStatus> = { success: true, data: status };
|
||||
ctx.response.body = body;
|
||||
});
|
||||
|
||||
// GET /api/follows/feed/users?page=&limit=
|
||||
router.get("/feed/users", authMiddleware, (ctx) => {
|
||||
const page = Math.max(
|
||||
1,
|
||||
parseInt(ctx.request.url.searchParams.get("page") ?? "1") || 1,
|
||||
);
|
||||
const limit = Math.min(
|
||||
Math.max(
|
||||
1,
|
||||
parseInt(ctx.request.url.searchParams.get("limit") ?? "20") || 20,
|
||||
),
|
||||
100,
|
||||
);
|
||||
const { items, total } = getFollowedUsersDumpFeed(
|
||||
ctx.state.user.userId as string,
|
||||
page,
|
||||
limit,
|
||||
);
|
||||
const data: PaginatedData<Dump> = {
|
||||
items,
|
||||
total,
|
||||
hasMore: page * limit < total,
|
||||
};
|
||||
const body: APIResponse<PaginatedData<Dump>> = { success: true, data };
|
||||
ctx.response.body = body;
|
||||
});
|
||||
|
||||
// GET /api/follows/feed/playlists?page=&limit=
|
||||
router.get("/feed/playlists", authMiddleware, (ctx) => {
|
||||
const page = Math.max(
|
||||
1,
|
||||
parseInt(ctx.request.url.searchParams.get("page") ?? "1") || 1,
|
||||
);
|
||||
const limit = Math.min(
|
||||
Math.max(
|
||||
1,
|
||||
parseInt(ctx.request.url.searchParams.get("limit") ?? "20") || 20,
|
||||
),
|
||||
100,
|
||||
);
|
||||
const { items, total } = getFollowedPlaylistsDumpFeed(
|
||||
ctx.state.user.userId as string,
|
||||
page,
|
||||
limit,
|
||||
);
|
||||
const data: PaginatedData<Dump> = {
|
||||
items,
|
||||
total,
|
||||
hasMore: page * limit < total,
|
||||
};
|
||||
const body: APIResponse<PaginatedData<Dump>> = { success: true, data };
|
||||
ctx.response.body = body;
|
||||
});
|
||||
|
||||
// POST /api/follows/users/:userId
|
||||
router.post("/users/:userId", authMiddleware, (ctx) => {
|
||||
followUser(ctx.state.user.userId as string, ctx.params.userId);
|
||||
ctx.response.status = 204;
|
||||
});
|
||||
|
||||
// DELETE /api/follows/users/:userId
|
||||
router.delete("/users/:userId", authMiddleware, (ctx) => {
|
||||
unfollowUser(ctx.state.user.userId as string, ctx.params.userId);
|
||||
ctx.response.status = 204;
|
||||
});
|
||||
|
||||
// POST /api/follows/playlists/:playlistId
|
||||
router.post("/playlists/:playlistId", authMiddleware, (ctx) => {
|
||||
followPlaylist(ctx.state.user.userId as string, ctx.params.playlistId);
|
||||
ctx.response.status = 204;
|
||||
});
|
||||
|
||||
// DELETE /api/follows/playlists/:playlistId
|
||||
router.delete("/playlists/:playlistId", authMiddleware, (ctx) => {
|
||||
unfollowPlaylist(ctx.state.user.userId as string, ctx.params.playlistId);
|
||||
ctx.response.status = 204;
|
||||
});
|
||||
|
||||
export default router;
|
||||
32
api/routes/invites.ts
Normal file
32
api/routes/invites.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Router } from "@oak/oak";
|
||||
import { APIErrorCode, APIException } from "../model/interfaces.ts";
|
||||
import { type AuthContext, authMiddleware } from "../middleware/auth.ts";
|
||||
import { createInvite, validateInvite } from "../services/invite-service.ts";
|
||||
|
||||
const router = new Router({ prefix: "/api/invites" });
|
||||
|
||||
// Create a new invite link (any authenticated user)
|
||||
router.post("/", authMiddleware, async (ctx: AuthContext) => {
|
||||
if (!ctx.state.user) {
|
||||
throw new APIException(APIErrorCode.UNAUTHORIZED, 401, "Not authenticated");
|
||||
}
|
||||
const token = await createInvite(ctx.state.user.userId);
|
||||
ctx.response.status = 201;
|
||||
ctx.response.body = { success: true, data: { token } };
|
||||
});
|
||||
|
||||
// Validate an invite token (used by the register page before showing the form)
|
||||
router.get("/:token", async (ctx) => {
|
||||
try {
|
||||
await validateInvite(ctx.params.token);
|
||||
ctx.response.body = { success: true };
|
||||
} catch {
|
||||
throw new APIException(
|
||||
APIErrorCode.NOT_FOUND,
|
||||
404,
|
||||
"Invalid or expired invite",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
67
api/routes/notifications.ts
Normal file
67
api/routes/notifications.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { Router } from "@oak/oak";
|
||||
import {
|
||||
APIErrorCode,
|
||||
APIException,
|
||||
type AuthPayload,
|
||||
type PaginatedData,
|
||||
} from "../model/interfaces.ts";
|
||||
import { type AuthContext, authMiddleware } from "../middleware/auth.ts";
|
||||
import {
|
||||
getNotificationsForUser,
|
||||
markAllRead,
|
||||
markOneRead,
|
||||
} from "../services/notification-service.ts";
|
||||
|
||||
const router = new Router({ prefix: "/api/notifications" });
|
||||
|
||||
// GET /api/notifications?page=N&limit=N
|
||||
router.get("/", authMiddleware, (ctx: AuthContext) => {
|
||||
if (!ctx.state.user) {
|
||||
throw new APIException(APIErrorCode.UNAUTHORIZED, 401, "Not authenticated");
|
||||
}
|
||||
const page = Math.max(
|
||||
1,
|
||||
parseInt(ctx.request.url.searchParams.get("page") ?? "1") || 1,
|
||||
);
|
||||
const limit = Math.min(
|
||||
Math.max(
|
||||
1,
|
||||
parseInt(ctx.request.url.searchParams.get("limit") ?? "20") || 20,
|
||||
),
|
||||
100,
|
||||
);
|
||||
const { items, total } = getNotificationsForUser(
|
||||
ctx.state.user.userId,
|
||||
page,
|
||||
limit,
|
||||
);
|
||||
ctx.response.body = {
|
||||
success: true,
|
||||
data: {
|
||||
items,
|
||||
total,
|
||||
hasMore: page * limit < total,
|
||||
} satisfies PaginatedData<typeof items[number]>,
|
||||
};
|
||||
});
|
||||
|
||||
// POST /api/notifications/read-all
|
||||
router.post("/read-all", authMiddleware, (ctx: AuthContext) => {
|
||||
if (!ctx.state.user) {
|
||||
throw new APIException(APIErrorCode.UNAUTHORIZED, 401, "Not authenticated");
|
||||
}
|
||||
markAllRead(ctx.state.user.userId);
|
||||
ctx.response.status = 204;
|
||||
});
|
||||
|
||||
// PATCH /api/notifications/:id/read
|
||||
router.patch("/:id/read", authMiddleware, (ctx) => {
|
||||
const user = ctx.state.user as AuthPayload;
|
||||
if (!user) {
|
||||
throw new APIException(APIErrorCode.UNAUTHORIZED, 401, "Not authenticated");
|
||||
}
|
||||
markOneRead(ctx.params.id, user.userId);
|
||||
ctx.response.status = 204;
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -15,52 +15,48 @@ import {
|
||||
getUserById,
|
||||
getUserByUsername,
|
||||
} from "../services/user-service.ts";
|
||||
import { redeemInvite, validateInvite } from "../services/invite-service.ts";
|
||||
import {
|
||||
getDumpsByUser,
|
||||
getVotedDumpsByUser,
|
||||
} from "../services/dump-service.ts";
|
||||
import { listPlaylistsByUser } from "../services/playlist-service.ts";
|
||||
import { getFollowedPlaylistsByUser } from "../services/follow-service.ts";
|
||||
|
||||
// Users router
|
||||
const router = new Router({ prefix: "/api/users" });
|
||||
|
||||
// Register a new user
|
||||
// Register a new user (requires a valid invite token)
|
||||
router.post("/register", async (ctx) => {
|
||||
try {
|
||||
const body = await ctx.request.body.json();
|
||||
|
||||
if (!isRegisterUserRequest(body)) {
|
||||
throw new APIException(
|
||||
APIErrorCode.VALIDATION_ERROR,
|
||||
400,
|
||||
"Invalid request",
|
||||
);
|
||||
}
|
||||
|
||||
const user = await createUser(body);
|
||||
const token = await createJWT({
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
isAdmin: user.isAdmin,
|
||||
});
|
||||
|
||||
ctx.response.status = 201;
|
||||
ctx.response.body = {
|
||||
success: true,
|
||||
data: {
|
||||
token,
|
||||
user,
|
||||
},
|
||||
};
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
const body = await ctx.request.body.json();
|
||||
|
||||
if (!isRegisterUserRequest(body)) {
|
||||
throw new APIException(
|
||||
APIErrorCode.SERVER_ERROR,
|
||||
500,
|
||||
"Failed to register user",
|
||||
APIErrorCode.VALIDATION_ERROR,
|
||||
400,
|
||||
"Invalid request",
|
||||
);
|
||||
}
|
||||
|
||||
// Validate invite — throws 404/409 if bad
|
||||
const inviterId = await validateInvite(body.inviteToken);
|
||||
|
||||
const user = await createUser(body, inviterId);
|
||||
|
||||
// Mark invite as used only after the user row is committed
|
||||
redeemInvite(body.inviteToken);
|
||||
|
||||
const authToken = await createJWT({
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
isAdmin: user.isAdmin,
|
||||
});
|
||||
|
||||
ctx.response.status = 201;
|
||||
ctx.response.body = {
|
||||
success: true,
|
||||
data: { token: authToken, user },
|
||||
};
|
||||
});
|
||||
|
||||
// Login
|
||||
@@ -142,6 +138,31 @@ router.get("/by-id/:userId", (ctx) => {
|
||||
ctx.response.body = { success: true, data: publicUser };
|
||||
});
|
||||
|
||||
// Followed playlists for a user (public only)
|
||||
router.get("/:username/followed-playlists", (ctx) => {
|
||||
const user = getUserByUsername(ctx.params.username);
|
||||
const page = Math.max(
|
||||
1,
|
||||
parseInt(ctx.request.url.searchParams.get("page") ?? "1") || 1,
|
||||
);
|
||||
const limit = Math.min(
|
||||
Math.max(
|
||||
1,
|
||||
parseInt(ctx.request.url.searchParams.get("limit") ?? "20") || 20,
|
||||
),
|
||||
100,
|
||||
);
|
||||
const { items, total } = getFollowedPlaylistsByUser(user.id, page, limit);
|
||||
ctx.response.body = {
|
||||
success: true,
|
||||
data: {
|
||||
items,
|
||||
total,
|
||||
hasMore: page * limit < total,
|
||||
} satisfies PaginatedData<typeof items[number]>,
|
||||
};
|
||||
});
|
||||
|
||||
// Playlists by user (optional auth: include private only if requester === owner)
|
||||
router.get("/:username/playlists", async (ctx) => {
|
||||
const user = getUserByUsername(ctx.params.username);
|
||||
@@ -151,12 +172,30 @@ router.get("/:username/playlists", async (ctx) => {
|
||||
const payload = await verifyJWT(authHeader.substring(7));
|
||||
if (payload) requestingUserId = payload.userId;
|
||||
}
|
||||
const page = Math.max(1, parseInt(ctx.request.url.searchParams.get("page") ?? "1") || 1);
|
||||
const limit = Math.min(Math.max(1, parseInt(ctx.request.url.searchParams.get("limit") ?? "20") || 20), 100);
|
||||
const { items, total } = listPlaylistsByUser(user.id, requestingUserId, page, limit);
|
||||
const page = Math.max(
|
||||
1,
|
||||
parseInt(ctx.request.url.searchParams.get("page") ?? "1") || 1,
|
||||
);
|
||||
const limit = Math.min(
|
||||
Math.max(
|
||||
1,
|
||||
parseInt(ctx.request.url.searchParams.get("limit") ?? "20") || 20,
|
||||
),
|
||||
100,
|
||||
);
|
||||
const { items, total } = listPlaylistsByUser(
|
||||
user.id,
|
||||
requestingUserId,
|
||||
page,
|
||||
limit,
|
||||
);
|
||||
ctx.response.body = {
|
||||
success: true,
|
||||
data: { items, total, hasMore: page * limit < total } satisfies PaginatedData<typeof items[number]>,
|
||||
data: {
|
||||
items,
|
||||
total,
|
||||
hasMore: page * limit < total,
|
||||
} satisfies PaginatedData<typeof items[number]>,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -176,13 +215,26 @@ router.get("/:username/dumps", async (ctx) => {
|
||||
const payload = await verifyJWT(authHeader.substring(7));
|
||||
if (payload) requestingUserId = payload.userId;
|
||||
}
|
||||
const page = Math.max(1, parseInt(ctx.request.url.searchParams.get("page") ?? "1") || 1);
|
||||
const limit = Math.min(Math.max(1, parseInt(ctx.request.url.searchParams.get("limit") ?? "20") || 20), 100);
|
||||
const page = Math.max(
|
||||
1,
|
||||
parseInt(ctx.request.url.searchParams.get("page") ?? "1") || 1,
|
||||
);
|
||||
const limit = Math.min(
|
||||
Math.max(
|
||||
1,
|
||||
parseInt(ctx.request.url.searchParams.get("limit") ?? "20") || 20,
|
||||
),
|
||||
100,
|
||||
);
|
||||
const includePrivate = requestingUserId === user.id;
|
||||
const { items, total } = getDumpsByUser(user.id, page, limit, includePrivate);
|
||||
ctx.response.body = {
|
||||
success: true,
|
||||
data: { items, total, hasMore: page * limit < total } satisfies PaginatedData<typeof items[number]>,
|
||||
data: {
|
||||
items,
|
||||
total,
|
||||
hasMore: page * limit < total,
|
||||
} satisfies PaginatedData<typeof items[number]>,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -195,12 +247,30 @@ router.get("/:username/votes", async (ctx) => {
|
||||
const payload = await verifyJWT(authHeader.substring(7));
|
||||
if (payload) requestingUserId = payload.userId;
|
||||
}
|
||||
const page = Math.max(1, parseInt(ctx.request.url.searchParams.get("page") ?? "1") || 1);
|
||||
const limit = Math.min(Math.max(1, parseInt(ctx.request.url.searchParams.get("limit") ?? "20") || 20), 100);
|
||||
const { items, total } = getVotedDumpsByUser(user.id, page, limit, requestingUserId);
|
||||
const page = Math.max(
|
||||
1,
|
||||
parseInt(ctx.request.url.searchParams.get("page") ?? "1") || 1,
|
||||
);
|
||||
const limit = Math.min(
|
||||
Math.max(
|
||||
1,
|
||||
parseInt(ctx.request.url.searchParams.get("limit") ?? "20") || 20,
|
||||
),
|
||||
100,
|
||||
);
|
||||
const { items, total } = getVotedDumpsByUser(
|
||||
user.id,
|
||||
page,
|
||||
limit,
|
||||
requestingUserId,
|
||||
);
|
||||
ctx.response.body = {
|
||||
success: true,
|
||||
data: { items, total, hasMore: page * limit < total } satisfies PaginatedData<typeof items[number]>,
|
||||
data: {
|
||||
items,
|
||||
total,
|
||||
hasMore: page * limit < total,
|
||||
} satisfies PaginatedData<typeof items[number]>,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
getUserVotes,
|
||||
removeVote,
|
||||
} from "../services/vote-service.ts";
|
||||
import { getUnreadCount } from "../services/notification-service.ts";
|
||||
import { getUserById } from "../services/user-service.ts";
|
||||
import { APIException } from "../model/interfaces.ts";
|
||||
|
||||
@@ -61,10 +62,14 @@ router.get("/ws", async (ctx) => {
|
||||
|
||||
try {
|
||||
const myVotes = authPayload ? getUserVotes(authPayload.userId) : [];
|
||||
const unreadNotificationCount = authPayload
|
||||
? getUnreadCount(authPayload.userId)
|
||||
: 0;
|
||||
socket.send(JSON.stringify({
|
||||
type: "welcome",
|
||||
users: getOnlineUsers(),
|
||||
myVotes,
|
||||
unreadNotificationCount,
|
||||
}));
|
||||
} catch (err) {
|
||||
console.error("[ws] welcome send failed:", err);
|
||||
|
||||
@@ -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) };
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
259
api/services/follow-service.ts
Normal file
259
api/services/follow-service.ts
Normal 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 };
|
||||
}
|
||||
53
api/services/invite-service.ts
Normal file
53
api/services/invite-service.ts
Normal 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);
|
||||
}
|
||||
212
api/services/notification-service.ts
Normal file
212
api/services/notification-service.ts
Normal 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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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`,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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;");
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -21,7 +21,8 @@ CREATE TABLE users (
|
||||
password_hash TEXT NOT NULL,
|
||||
is_admin INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
avatar_mime TEXT
|
||||
avatar_mime TEXT,
|
||||
invited_by TEXT REFERENCES users(id)
|
||||
);
|
||||
|
||||
CREATE TABLE votes (
|
||||
@@ -33,7 +34,6 @@ CREATE TABLE votes (
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- v2: playlists
|
||||
CREATE TABLE playlists (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
@@ -55,7 +55,6 @@ CREATE TABLE playlist_dumps (
|
||||
FOREIGN KEY (dump_id) REFERENCES dumps(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- v3: comments
|
||||
CREATE TABLE comments (
|
||||
id TEXT PRIMARY KEY,
|
||||
dump_id TEXT NOT NULL,
|
||||
@@ -75,3 +74,52 @@ CREATE INDEX idx_playlists_user ON playlists(user_id);
|
||||
CREATE INDEX idx_playlist_dumps_order ON playlist_dumps(playlist_id, position);
|
||||
CREATE INDEX idx_playlist_dumps_dump ON playlist_dumps(dump_id);
|
||||
CREATE INDEX idx_comments_dump ON comments(dump_id, created_at);
|
||||
|
||||
CREATE TABLE follows (
|
||||
id TEXT PRIMARY KEY,
|
||||
follower_id TEXT NOT NULL,
|
||||
followed_user_id TEXT,
|
||||
followed_playlist_id TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY (follower_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (followed_user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (followed_playlist_id) REFERENCES playlists(id) ON DELETE CASCADE,
|
||||
CHECK (
|
||||
(followed_user_id IS NOT NULL AND followed_playlist_id IS NULL)
|
||||
OR
|
||||
(followed_user_id IS NULL AND followed_playlist_id IS NOT NULL)
|
||||
)
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX idx_follows_user
|
||||
ON follows(follower_id, followed_user_id)
|
||||
WHERE followed_user_id IS NOT NULL;
|
||||
|
||||
CREATE UNIQUE INDEX idx_follows_playlist
|
||||
ON follows(follower_id, followed_playlist_id)
|
||||
WHERE followed_playlist_id IS NOT NULL;
|
||||
|
||||
CREATE INDEX idx_follows_follower ON follows(follower_id);
|
||||
|
||||
CREATE TABLE invites (
|
||||
token TEXT PRIMARY KEY,
|
||||
inviter_id TEXT NOT NULL,
|
||||
used_at TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY (inviter_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE notifications (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
data TEXT NOT NULL,
|
||||
read INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
source_key TEXT,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX idx_notifications_user ON notifications(user_id, created_at);
|
||||
CREATE UNIQUE INDEX idx_notifications_dedup
|
||||
ON notifications(user_id, source_key)
|
||||
WHERE source_key IS NOT NULL;
|
||||
|
||||
Reference in New Issue
Block a user