v3: added content slugs, fixed real-time updates in client, added @mentions across the app, added new file selector and drop zone
This commit is contained in:
@@ -8,10 +8,41 @@ import {
|
||||
type RichContent,
|
||||
type User,
|
||||
} from "./interfaces.ts";
|
||||
import { makeSlug } from "../lib/slugify.ts";
|
||||
|
||||
export const db = new DatabaseSync("api/sql/gerbeur.db");
|
||||
db.exec("PRAGMA foreign_keys = ON;");
|
||||
|
||||
// Add columns to existing tables if missing (idempotent migrations)
|
||||
for (
|
||||
const [table, col, def] of [
|
||||
["dumps", "updated_at", "TEXT"],
|
||||
["users", "updated_at", "TEXT"],
|
||||
["playlists", "updated_at", "TEXT"],
|
||||
["comments", "updated_at", "TEXT"],
|
||||
["dumps", "slug", "TEXT"],
|
||||
["playlists", "slug", "TEXT"],
|
||||
] as [string, string, string][]
|
||||
) {
|
||||
const cols = db.prepare(`PRAGMA table_info(${table})`).all() as {
|
||||
name: string;
|
||||
}[];
|
||||
if (!cols.some((c) => c.name === col)) {
|
||||
db.exec(`ALTER TABLE ${table} ADD COLUMN ${col} ${def};`);
|
||||
}
|
||||
}
|
||||
|
||||
// Backfill slugs for any records created before this migration
|
||||
for (const table of ["dumps", "playlists"] as const) {
|
||||
const rows = db.prepare(
|
||||
`SELECT id, title FROM ${table} WHERE slug IS NULL;`,
|
||||
).all() as { id: string; title: string }[];
|
||||
const update = db.prepare(`UPDATE ${table} SET slug = ? WHERE id = ?;`);
|
||||
for (const row of rows) {
|
||||
update.run(makeSlug(row.title, row.id), row.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Purge expired unused invites on startup
|
||||
db.prepare(
|
||||
`DELETE FROM invites WHERE used_at IS NULL AND created_at < datetime('now', '-7 days');`,
|
||||
@@ -28,6 +59,8 @@ export interface DumpRow {
|
||||
comment: string | null;
|
||||
user_id: string;
|
||||
created_at: string;
|
||||
updated_at: string | null;
|
||||
slug: string | null;
|
||||
url: string | null;
|
||||
rich_content: string | null;
|
||||
file_name: string | null;
|
||||
@@ -45,6 +78,7 @@ export interface UserRow {
|
||||
password_hash: string;
|
||||
is_admin: number;
|
||||
created_at: string;
|
||||
updated_at: string | null;
|
||||
avatar_mime: string | null;
|
||||
invited_by: string | null;
|
||||
// Present only when joined: LEFT JOIN users i ON i.id = u.invited_by
|
||||
@@ -100,9 +134,11 @@ export function dumpRowToApi(row: DumpRow): Dump {
|
||||
id: row.id,
|
||||
kind: row.kind as "url" | "file",
|
||||
title: row.title,
|
||||
slug: row.slug ?? undefined,
|
||||
comment: row.comment ?? undefined,
|
||||
userId: row.user_id,
|
||||
createdAt: new Date(row.created_at),
|
||||
updatedAt: row.updated_at ? new Date(row.updated_at) : undefined,
|
||||
url: row.url ?? undefined,
|
||||
richContent: row.rich_content
|
||||
? (JSON.parse(row.rich_content) as RichContent)
|
||||
@@ -121,9 +157,11 @@ export function dumpApiToRow(dump: Dump): DumpRow {
|
||||
id: dump.id,
|
||||
kind: dump.kind,
|
||||
title: dump.title,
|
||||
slug: dump.slug ?? null,
|
||||
comment: dump.comment ?? null,
|
||||
user_id: dump.userId,
|
||||
created_at: dump.createdAt.toISOString(),
|
||||
updated_at: dump.updatedAt?.toISOString() ?? null,
|
||||
url: dump.url ?? null,
|
||||
rich_content: dump.richContent ? JSON.stringify(dump.richContent) : null,
|
||||
file_name: dump.fileName ?? null,
|
||||
@@ -142,6 +180,7 @@ export function userRowToApi(row: UserRow): User {
|
||||
passwordHash: row.password_hash,
|
||||
isAdmin: Boolean(row.is_admin),
|
||||
createdAt: new Date(row.created_at),
|
||||
updatedAt: row.updated_at ? new Date(row.updated_at) : undefined,
|
||||
avatarMime: row.avatar_mime ?? undefined,
|
||||
invitedByUsername: typeof row.invited_by_username === "string"
|
||||
? row.invited_by_username
|
||||
@@ -156,6 +195,7 @@ export function userApiToRow(user: User): UserRow {
|
||||
password_hash: user.passwordHash,
|
||||
is_admin: user.isAdmin ? 1 : 0,
|
||||
created_at: user.createdAt.toISOString(),
|
||||
updated_at: user.updatedAt?.toISOString() ?? null,
|
||||
avatar_mime: user.avatarMime ?? null,
|
||||
invited_by: null,
|
||||
invited_by_username: null,
|
||||
@@ -169,6 +209,7 @@ export interface CommentRow {
|
||||
parent_id: string | null;
|
||||
body: string;
|
||||
created_at: string;
|
||||
updated_at: string | null;
|
||||
deleted: number;
|
||||
author_username: string;
|
||||
author_avatar_mime: string | null;
|
||||
@@ -199,6 +240,7 @@ export function commentRowToApi(row: CommentRow): Comment {
|
||||
parentId: row.parent_id ?? undefined,
|
||||
body: row.body,
|
||||
createdAt: new Date(row.created_at),
|
||||
updatedAt: row.updated_at ? new Date(row.updated_at) : undefined,
|
||||
deleted: Boolean(row.deleted),
|
||||
authorUsername: row.author_username,
|
||||
authorAvatarMime: row.author_avatar_mime ?? undefined,
|
||||
@@ -209,9 +251,11 @@ export interface PlaylistRow {
|
||||
id: string;
|
||||
user_id: string;
|
||||
title: string;
|
||||
slug: string | null;
|
||||
description: string | null;
|
||||
is_public: number;
|
||||
created_at: string;
|
||||
updated_at: string | null;
|
||||
image_mime: string | null;
|
||||
[key: string]: SQLOutputValue;
|
||||
}
|
||||
@@ -231,9 +275,11 @@ export function playlistRowToApi(row: PlaylistRow): Playlist {
|
||||
id: row.id,
|
||||
userId: row.user_id,
|
||||
title: row.title,
|
||||
slug: row.slug ?? undefined,
|
||||
description: row.description ?? undefined,
|
||||
isPublic: Boolean(row.is_public),
|
||||
createdAt: new Date(row.created_at),
|
||||
updatedAt: row.updated_at ? new Date(row.updated_at) : undefined,
|
||||
imageMime: row.image_mime ?? undefined,
|
||||
dumpCount: typeof row.dump_count === "number" ? row.dump_count : undefined,
|
||||
ownerUsername: typeof row.owner_username === "string"
|
||||
|
||||
Reference in New Issue
Block a user