Friends + friends-only visibility + blocking
A fourth visibility level ("Venner") with one-way friendship and
two-way block filtering, plus the table-rebuild migration that drags
older dev DBs forward.
Visibility model:
- Friendship is directional: (owner, friend) means owner has added
friend to their list. Owner's friends-only activities become
visible to friend; friend isn't automatically friends with owner.
- Blocking is also directional at the DB level (blocker, blocked)
but is checked SYMMETRICALLY at visibility-resolution time: once
either user has blocked the other, friends-only content stops
flowing in either direction. Block does NOT affect public or
anonymous content — those are open to anyone by definition.
- "Friends-only" is an access-list visibility, NOT cryptographic.
The server stores the content in plaintext and serves it only to
authorised viewers. This is documented honestly in /personvern.
Schema:
- activities.visibility CHECK gains 'friends' as a fourth value
- friends(owner_id, friend_id, created_at) — composite PK,
self-friending blocked by CHECK
- user_blocks(blocker_id, blocked_id, created_at) — same shape,
blocking-self also blocked
Migration (server/db.ts):
- SQLite can't ALTER a CHECK constraint, so the migration detects
out-of-date DBs by scanning sqlite_master for the literal
"'friends'" in the activities table's CREATE statement
- If absent, rebuilds activities via the standard SQLite
table-copy-drop-rename dance with foreign_keys briefly off
around the transaction, then runs foreign_key_check to confirm
no FKs were left orphaned (activity_tags, activity_hearts,
bookmarks all point at activities). Smoke-tested on the dev DB:
olemd's user row and moderator/admin flags survived.
Server endpoints (server/friends.ts):
GET /api/friends — my outgoing list
GET /api/friends/incoming — who has added ME
POST /api/friends — add by username (idempotent)
DELETE /api/friends/:userId — remove a friend
GET /api/friends/blocks — my blocked-users list
POST /api/friends/blocks — block by user_id (idempotent)
DELETE /api/friends/blocks/:userId — unblock
Add-by-username (not by email): users must set a username to be
findable. Email stays a private contact identifier.
Activity list filter (server/activities.ts): adds two clauses to the
WHERE — own friends-only, and friends-only owned by a user who has
added me AND there's no block in either direction. Single-activity
GET applies the same check.
Frontend:
- ActivityForm.svelte gains the "Venner" option
- ActivityRow.svelte renders a "Venner" badge with a new amber
vis-badge.friends colour (passes contrast in both themes)
- FriendsPanel.svelte: add-by-username form, outgoing, incoming
(with Block button), and blocked (with Unblock button)
- Profile.svelte mounts FriendsPanel between display fields and
Eksporter
- Home.svelte adds a "Venner" section between private and semi
Docs: Personvern.svelte gains a "Venner og blokkering" section
explaining that friends-only is access-list-not-crypto and pointing
the reader at "private" for actually-sensitive content.
26 tests still pass; typecheck clean; build succeeds. Bundle
36.8 KB → 39.1 KB gzipped (FriendsPanel + new server endpoints +
the Personvern prose).
This commit is contained in:
parent
79ce7059c1
commit
f39fe9ed65
14 changed files with 657 additions and 8 deletions
|
|
@ -20,7 +20,7 @@ import type {
|
|||
*/
|
||||
export const activitiesRoutes = new Hono<{ Variables: AppVariables }>();
|
||||
|
||||
const VALID_VIS = new Set<Visibility>(['private', 'semi', 'public']);
|
||||
const VALID_VIS = new Set<Visibility>(['private', 'semi', 'public', 'friends']);
|
||||
|
||||
interface ActivityRow {
|
||||
id: string;
|
||||
|
|
@ -155,6 +155,31 @@ function serialize(row: ActivityRow, viewerId: string | null): Activity {
|
|||
return a;
|
||||
}
|
||||
const attrib = ownerAttribution(row.owner_id);
|
||||
if (row.visibility === 'friends') {
|
||||
// Visibility check already happened upstream; we only get here if the
|
||||
// viewer is allowed to see the row. Attribution is fine because being a
|
||||
// friend implies knowing the owner.
|
||||
const attribF = ownerAttribution(row.owner_id);
|
||||
return {
|
||||
id: row.id,
|
||||
visibility: 'friends',
|
||||
owner_id: row.owner_id,
|
||||
owner_display: attribF.display,
|
||||
owner_username: attribF.username,
|
||||
title: row.title ?? '',
|
||||
description: row.description,
|
||||
tags,
|
||||
loc_label: row.loc_label,
|
||||
loc_lat: row.loc_lat,
|
||||
loc_lng: row.loc_lng,
|
||||
scheduled_at: row.scheduled_at,
|
||||
heart_count: hearts.count,
|
||||
viewer_hearted: hearts.hearted,
|
||||
viewer_bookmarked: bookmarked,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
};
|
||||
}
|
||||
const a: ActivityPublic = {
|
||||
id: row.id,
|
||||
visibility: 'public',
|
||||
|
|
@ -201,7 +226,8 @@ function validateForVisibility(body: CreateActivityRequest): string | null {
|
|||
|
||||
// --- GET /api/activities ----------------------------------------------------
|
||||
// Returns: all public + semi activities (visible to anyone), plus the caller's
|
||||
// own private activities (if logged in).
|
||||
// own private activities AND friends-only activities owned by users who have
|
||||
// added the caller to their friends list (with mutual block-filtering).
|
||||
activitiesRoutes.get('/', (c) => {
|
||||
const viewerId = currentUserId(c);
|
||||
const db = getDb();
|
||||
|
|
@ -209,8 +235,28 @@ activitiesRoutes.get('/', (c) => {
|
|||
const params: string[] = [];
|
||||
let where = `visibility IN ('public','semi')`;
|
||||
if (viewerId) {
|
||||
// Own private:
|
||||
where += ` OR (visibility = 'private' AND owner_id = ?)`;
|
||||
params.push(viewerId);
|
||||
// Own friends-only (always visible to you, even if you don't appear in
|
||||
// your own friend list — which would be impossible anyway given the
|
||||
// self-friending CHECK):
|
||||
where += ` OR (visibility = 'friends' AND owner_id = ?)`;
|
||||
params.push(viewerId);
|
||||
// Friends-only owned by someone who has added me, with no block in
|
||||
// either direction.
|
||||
where += `
|
||||
OR (
|
||||
visibility = 'friends'
|
||||
AND owner_id IN (SELECT owner_id FROM friends WHERE friend_id = ?)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM user_blocks
|
||||
WHERE (blocker_id = activities.owner_id AND blocked_id = ?)
|
||||
OR (blocker_id = ? AND blocked_id = activities.owner_id)
|
||||
)
|
||||
)
|
||||
`;
|
||||
params.push(viewerId, viewerId, viewerId);
|
||||
}
|
||||
|
||||
const rows = db
|
||||
|
|
@ -225,9 +271,31 @@ activitiesRoutes.get('/:id', (c) => {
|
|||
const viewerId = currentUserId(c);
|
||||
const row = getDb().prepare('SELECT * FROM activities WHERE id = ?').get(c.req.param('id')) as ActivityRow | null;
|
||||
if (!row) return c.json({ error: 'not_found' }, 404);
|
||||
|
||||
// Apply the same visibility rules as the list endpoint. We return 404
|
||||
// (not 403) for hidden rows so the endpoint doesn't double as an existence
|
||||
// oracle for activity ids.
|
||||
if (row.visibility === 'private' && row.owner_id !== viewerId) {
|
||||
return c.json({ error: 'not_found' }, 404);
|
||||
}
|
||||
if (row.visibility === 'friends') {
|
||||
if (!viewerId) return c.json({ error: 'not_found' }, 404);
|
||||
if (row.owner_id !== viewerId) {
|
||||
const db = getDb();
|
||||
const isFriend = !!db
|
||||
.prepare('SELECT 1 FROM friends WHERE owner_id = ? AND friend_id = ?')
|
||||
.get(row.owner_id, viewerId);
|
||||
if (!isFriend) return c.json({ error: 'not_found' }, 404);
|
||||
const blocked = !!db
|
||||
.prepare(`
|
||||
SELECT 1 FROM user_blocks
|
||||
WHERE (blocker_id = ? AND blocked_id = ?)
|
||||
OR (blocker_id = ? AND blocked_id = ?)
|
||||
`)
|
||||
.get(row.owner_id, viewerId, viewerId, row.owner_id);
|
||||
if (blocked) return c.json({ error: 'not_found' }, 404);
|
||||
}
|
||||
}
|
||||
return c.json(serialize(row, viewerId));
|
||||
});
|
||||
|
||||
|
|
|
|||
98
server/db.ts
98
server/db.ts
|
|
@ -54,7 +54,14 @@ const SCHEMA_STATEMENTS: readonly string[] = [
|
|||
`CREATE TABLE IF NOT EXISTS activities (
|
||||
id TEXT PRIMARY KEY,
|
||||
owner_id TEXT NOT NULL REFERENCES users(id),
|
||||
visibility TEXT NOT NULL CHECK (visibility IN ('private','semi','public')),
|
||||
-- 'friends' = visible only to the owner's friend list, with blocks filtering
|
||||
-- in both directions. NOT encrypted server-side (it's an access-list
|
||||
-- visibility, not a cryptographic one); see /personvern for the trade-off.
|
||||
-- NOTE: SQLite can't ALTER a CHECK constraint; old scaffold DBs created
|
||||
-- before this commit will still have the 3-value CHECK and reject
|
||||
-- friends-only inserts. Drop and recreate the DB for dev, or run a
|
||||
-- table-rebuild migration for production.
|
||||
visibility TEXT NOT NULL CHECK (visibility IN ('private','semi','public','friends')),
|
||||
ciphertext BLOB,
|
||||
nonce BLOB,
|
||||
title TEXT,
|
||||
|
|
@ -140,6 +147,32 @@ const SCHEMA_STATEMENTS: readonly string[] = [
|
|||
claimed_by_user_id TEXT REFERENCES users(id)
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS invites_inviter_idx ON invites(inviter_user_id)`,
|
||||
// Friends: one-way edges. (owner_id, friend_id) means owner_id has added
|
||||
// friend_id to their friend list. owner_id's friends-only activities are
|
||||
// visible to friend_id (subject to block filtering below).
|
||||
`CREATE TABLE IF NOT EXISTS friends (
|
||||
owner_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
friend_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (owner_id, friend_id),
|
||||
-- Sanity: can't friend yourself. Saves a UI check.
|
||||
CHECK (owner_id <> friend_id)
|
||||
)`,
|
||||
// Lookup-by-friend (the incoming list) needs an index because the PK is
|
||||
// (owner_id, friend_id) — leftmost column is owner_id, so queries by
|
||||
// friend_id alone would full-scan without help.
|
||||
`CREATE INDEX IF NOT EXISTS friends_friend_idx ON friends(friend_id)`,
|
||||
// User blocks: directional. (blocker_id, blocked_id) means blocker_id has
|
||||
// blocked blocked_id. Friends-only visibility filters out content in
|
||||
// either direction (mutual exclusion).
|
||||
`CREATE TABLE IF NOT EXISTS user_blocks (
|
||||
blocker_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
blocked_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (blocker_id, blocked_id),
|
||||
CHECK (blocker_id <> blocked_id)
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS user_blocks_blocked_idx ON user_blocks(blocked_id)`,
|
||||
];
|
||||
|
||||
const PRAGMAS: readonly string[] = [
|
||||
|
|
@ -172,6 +205,65 @@ function ensureColumn(db: Database, table: string, column: string, type: string)
|
|||
db.prepare(`ALTER TABLE ${table} ADD COLUMN ${column} ${type}`).run();
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild the activities table if its CHECK constraint predates 'friends'.
|
||||
* SQLite cannot ALTER a CHECK constraint, so the only way forward is to copy
|
||||
* the table into a fresh one with the updated constraint and rename. Data
|
||||
* preserved; indexes recreated below. No-op if the constraint already
|
||||
* mentions 'friends'.
|
||||
*/
|
||||
function ensureActivitiesCheckIncludesFriends(db: Database): void {
|
||||
const row = db
|
||||
.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='activities'")
|
||||
.get() as { sql: string } | null;
|
||||
if (!row) return; // table didn't exist; CREATE TABLE just made it with the new check
|
||||
if (row.sql.includes("'friends'")) return; // already up to date
|
||||
|
||||
// SQLite's recommended table-rebuild dance. foreign_keys=OFF lets us
|
||||
// drop+rename without cascade-failing on activity_tags / activity_hearts /
|
||||
// bookmarks. After the rebuild we run foreign_key_check to confirm
|
||||
// everything still points somewhere real.
|
||||
db.prepare('PRAGMA foreign_keys = OFF').run();
|
||||
const txn = db.transaction(() => {
|
||||
db.prepare(`
|
||||
CREATE TABLE activities_new (
|
||||
id TEXT PRIMARY KEY,
|
||||
owner_id TEXT NOT NULL REFERENCES users(id),
|
||||
visibility TEXT NOT NULL CHECK (visibility IN ('private','semi','public','friends')),
|
||||
ciphertext BLOB,
|
||||
nonce BLOB,
|
||||
title TEXT,
|
||||
description TEXT,
|
||||
scheduled_at INTEGER,
|
||||
loc_label TEXT,
|
||||
loc_lat REAL,
|
||||
loc_lng REAL,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
)
|
||||
`).run();
|
||||
db.prepare(`
|
||||
INSERT INTO activities_new
|
||||
(id, owner_id, visibility, ciphertext, nonce, title, description,
|
||||
scheduled_at, loc_label, loc_lat, loc_lng, created_at, updated_at)
|
||||
SELECT id, owner_id, visibility, ciphertext, nonce, title, description,
|
||||
scheduled_at, loc_label, loc_lat, loc_lng, created_at, updated_at
|
||||
FROM activities
|
||||
`).run();
|
||||
db.prepare('DROP TABLE activities').run();
|
||||
db.prepare('ALTER TABLE activities_new RENAME TO activities').run();
|
||||
db.prepare('CREATE INDEX IF NOT EXISTS activities_visibility_idx ON activities(visibility)').run();
|
||||
db.prepare('CREATE INDEX IF NOT EXISTS activities_owner_idx ON activities(owner_id)').run();
|
||||
});
|
||||
txn();
|
||||
// foreign_key_check returns rows for any orphaned FK; should be empty.
|
||||
const orphans = db.prepare('PRAGMA foreign_key_check').all();
|
||||
if (orphans.length > 0) {
|
||||
throw new Error(`Migration left orphan FKs: ${JSON.stringify(orphans)}`);
|
||||
}
|
||||
db.prepare('PRAGMA foreign_keys = ON').run();
|
||||
}
|
||||
|
||||
export function getDb(): Database {
|
||||
if (dbInstance) return dbInstance;
|
||||
|
||||
|
|
@ -199,6 +291,10 @@ export function getDb(): Database {
|
|||
ensureColumn(db, 'feedback', 'done_at', 'INTEGER');
|
||||
ensureColumn(db, 'feedback', 'done_by', 'TEXT');
|
||||
ensureColumn(db, 'activities', 'description', 'TEXT');
|
||||
// Activities visibility CHECK: gained 'friends' as a fourth value. The
|
||||
// ALTER must rebuild the table, which is expensive — guarded inside the
|
||||
// helper.
|
||||
ensureActivitiesCheckIncludesFriends(db);
|
||||
ensureColumn(db, 'users', 'username', 'TEXT');
|
||||
ensureColumn(db, 'users', 'public_list_enabled', 'INTEGER NOT NULL DEFAULT 0');
|
||||
// UNIQUE index on username via separate CREATE INDEX so the ALTER TABLE
|
||||
|
|
|
|||
183
server/friends.ts
Normal file
183
server/friends.ts
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
import { Hono } from 'hono';
|
||||
import { getDb } from './db';
|
||||
import { requireAuth, type AppVariables } from './session';
|
||||
import type { FriendEntry, BlockEntry, AddByUsernameRequest } from '../shared/types';
|
||||
|
||||
/**
|
||||
* Friends and blocks.
|
||||
*
|
||||
* Friendship is one-way. (owner, friend) means owner has added friend to
|
||||
* their list; owner's friends-only activities become visible to friend
|
||||
* (subject to block filtering). Friend doesn't need to reciprocate.
|
||||
*
|
||||
* Blocks are also one-way at the DB level, but the visibility filter checks
|
||||
* blocks in *both directions* — once anyone in a pair blocks the other,
|
||||
* friends-only content between them stops flowing in either direction.
|
||||
*
|
||||
* Discovery: users are added by `username` (the URL slug). A user without a
|
||||
* username can't be added; this is intentional — opting into a username is
|
||||
* the "I want to be findable" signal. Documented in /personvern.
|
||||
*/
|
||||
export const friendsRoutes = new Hono<{ Variables: AppVariables }>();
|
||||
|
||||
interface UserLookupRow {
|
||||
id: string;
|
||||
username: string | null;
|
||||
display_name: string | null;
|
||||
}
|
||||
|
||||
function lookupByUsername(username: string): UserLookupRow | null {
|
||||
return getDb()
|
||||
.prepare('SELECT id, username, display_name FROM users WHERE username = ?')
|
||||
.get(username.trim().toLowerCase()) as UserLookupRow | null;
|
||||
}
|
||||
|
||||
function rowToEntry(row: {
|
||||
user_id: string;
|
||||
username: string | null;
|
||||
display_name: string | null;
|
||||
since: number;
|
||||
}): FriendEntry {
|
||||
return {
|
||||
user_id: row.user_id,
|
||||
username: row.username,
|
||||
display_name: row.display_name,
|
||||
since: row.since,
|
||||
};
|
||||
}
|
||||
|
||||
// --- GET /api/friends ------------------------------------------------------
|
||||
// My outgoing list (people I've added).
|
||||
friendsRoutes.get('/', requireAuth, (c) => {
|
||||
const userId = c.get('userId');
|
||||
const rows = getDb()
|
||||
.prepare(`
|
||||
SELECT f.friend_id AS user_id, u.username, u.display_name,
|
||||
f.created_at AS since
|
||||
FROM friends f
|
||||
JOIN users u ON u.id = f.friend_id
|
||||
WHERE f.owner_id = ?
|
||||
ORDER BY f.created_at DESC
|
||||
`)
|
||||
.all(userId) as { user_id: string; username: string | null; display_name: string | null; since: number }[];
|
||||
return c.json(rows.map(rowToEntry));
|
||||
});
|
||||
|
||||
// --- GET /api/friends/incoming ---------------------------------------------
|
||||
// Who has added ME as a friend.
|
||||
friendsRoutes.get('/incoming', requireAuth, (c) => {
|
||||
const userId = c.get('userId');
|
||||
const rows = getDb()
|
||||
.prepare(`
|
||||
SELECT f.owner_id AS user_id, u.username, u.display_name,
|
||||
f.created_at AS since
|
||||
FROM friends f
|
||||
JOIN users u ON u.id = f.owner_id
|
||||
WHERE f.friend_id = ?
|
||||
ORDER BY f.created_at DESC
|
||||
`)
|
||||
.all(userId) as { user_id: string; username: string | null; display_name: string | null; since: number }[];
|
||||
return c.json(rows.map(rowToEntry));
|
||||
});
|
||||
|
||||
// --- POST /api/friends -----------------------------------------------------
|
||||
// Add a friend by username. Idempotent — re-adding is a no-op rather than 409.
|
||||
friendsRoutes.post('/', requireAuth, async (c) => {
|
||||
const userId = c.get('userId');
|
||||
const body = (await c.req.json().catch(() => null)) as AddByUsernameRequest | null;
|
||||
if (!body || typeof body.username !== 'string' || !body.username.trim()) {
|
||||
return c.json({ error: 'missing:username' }, 400);
|
||||
}
|
||||
|
||||
const target = lookupByUsername(body.username);
|
||||
if (!target) return c.json({ error: 'user_not_found' }, 404);
|
||||
if (target.id === userId) return c.json({ error: 'cannot_friend_self' }, 400);
|
||||
|
||||
getDb()
|
||||
.prepare(
|
||||
'INSERT OR IGNORE INTO friends (owner_id, friend_id, created_at) VALUES (?, ?, ?)',
|
||||
)
|
||||
.run(userId, target.id, Date.now());
|
||||
|
||||
const since = (getDb()
|
||||
.prepare('SELECT created_at FROM friends WHERE owner_id = ? AND friend_id = ?')
|
||||
.get(userId, target.id) as { created_at: number }).created_at;
|
||||
|
||||
const entry: FriendEntry = {
|
||||
user_id: target.id,
|
||||
username: target.username,
|
||||
display_name: target.display_name,
|
||||
since,
|
||||
};
|
||||
return c.json(entry, 201);
|
||||
});
|
||||
|
||||
// --- DELETE /api/friends/:userId -------------------------------------------
|
||||
friendsRoutes.delete('/:userId', requireAuth, (c) => {
|
||||
const userId = c.get('userId');
|
||||
const targetId = c.req.param('userId');
|
||||
getDb().prepare('DELETE FROM friends WHERE owner_id = ? AND friend_id = ?').run(userId, targetId);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
// --- GET /api/friends/blocks -----------------------------------------------
|
||||
// My blocked-users list. Routed under /friends so the UI surface stays in
|
||||
// one place (friends + blocks are part of the same "people I deal with" page).
|
||||
friendsRoutes.get('/blocks', requireAuth, (c) => {
|
||||
const userId = c.get('userId');
|
||||
const rows = getDb()
|
||||
.prepare(`
|
||||
SELECT b.blocked_id AS user_id, u.username, u.display_name,
|
||||
b.created_at AS since
|
||||
FROM user_blocks b
|
||||
JOIN users u ON u.id = b.blocked_id
|
||||
WHERE b.blocker_id = ?
|
||||
ORDER BY b.created_at DESC
|
||||
`)
|
||||
.all(userId) as { user_id: string; username: string | null; display_name: string | null; since: number }[];
|
||||
const entries: BlockEntry[] = rows.map(rowToEntry);
|
||||
return c.json(entries);
|
||||
});
|
||||
|
||||
// --- POST /api/friends/blocks ----------------------------------------------
|
||||
// Block by user_id (the incoming list already gives us the id; no username
|
||||
// dance needed). Block is idempotent.
|
||||
friendsRoutes.post('/blocks', requireAuth, async (c) => {
|
||||
const userId = c.get('userId');
|
||||
const body = (await c.req.json().catch(() => null)) as { user_id?: string } | null;
|
||||
if (!body || typeof body.user_id !== 'string' || !body.user_id.trim()) {
|
||||
return c.json({ error: 'missing:user_id' }, 400);
|
||||
}
|
||||
if (body.user_id === userId) return c.json({ error: 'cannot_block_self' }, 400);
|
||||
|
||||
const target = getDb()
|
||||
.prepare('SELECT id, username, display_name FROM users WHERE id = ?')
|
||||
.get(body.user_id) as UserLookupRow | null;
|
||||
if (!target) return c.json({ error: 'user_not_found' }, 404);
|
||||
|
||||
getDb()
|
||||
.prepare(
|
||||
'INSERT OR IGNORE INTO user_blocks (blocker_id, blocked_id, created_at) VALUES (?, ?, ?)',
|
||||
)
|
||||
.run(userId, target.id, Date.now());
|
||||
|
||||
const since = (getDb()
|
||||
.prepare('SELECT created_at FROM user_blocks WHERE blocker_id = ? AND blocked_id = ?')
|
||||
.get(userId, target.id) as { created_at: number }).created_at;
|
||||
|
||||
const entry: BlockEntry = {
|
||||
user_id: target.id,
|
||||
username: target.username,
|
||||
display_name: target.display_name,
|
||||
since,
|
||||
};
|
||||
return c.json(entry, 201);
|
||||
});
|
||||
|
||||
// --- DELETE /api/friends/blocks/:userId ------------------------------------
|
||||
friendsRoutes.delete('/blocks/:userId', requireAuth, (c) => {
|
||||
const userId = c.get('userId');
|
||||
const targetId = c.req.param('userId');
|
||||
getDb().prepare('DELETE FROM user_blocks WHERE blocker_id = ? AND blocked_id = ?').run(userId, targetId);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
|
@ -9,6 +9,7 @@ import { feedbackRoutes } from './feedback';
|
|||
import { adminRoutes } from './admin';
|
||||
import { settingsRoutes } from './settings';
|
||||
import { invitesRoutes } from './invites';
|
||||
import { friendsRoutes } from './friends';
|
||||
|
||||
// Initialise DB up front so the server fails fast on schema problems.
|
||||
getDb();
|
||||
|
|
@ -33,6 +34,7 @@ app.route('/api/feedback', feedbackRoutes);
|
|||
app.route('/api/admin', adminRoutes);
|
||||
app.route('/api/settings', settingsRoutes);
|
||||
app.route('/api/invites', invitesRoutes);
|
||||
app.route('/api/friends', friendsRoutes);
|
||||
|
||||
// In production, serve the built Svelte SPA. The static helper is registered
|
||||
// for the asset directory and for the top-level files that Vite copies from
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue