Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 24 additions & 24 deletions CONTRIBUTING.md

Large diffs are not rendered by default.

177 changes: 113 additions & 64 deletions SPEC.md

Large diffs are not rendered by default.

37 changes: 22 additions & 15 deletions docs/handbook/endpoints.md

Large diffs are not rendered by default.

19 changes: 13 additions & 6 deletions docs/handbook/functions.md

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions docs/schema/message.sql
Original file line number Diff line number Diff line change
Expand Up @@ -89,3 +89,7 @@ ALTER TABLE account ADD CONSTRAINT account_profile_message_id_fkey
FOREIGN KEY (profile_message_id) REFERENCES message (id) ON DELETE SET NULL;
CREATE UNIQUE INDEX IF NOT EXISTS account_profile_message_uidx
ON account (profile_message_id) WHERE profile_message_id IS NOT NULL;

-- Soft-hide stamps (HTTP DELETE /messages/:id). No FK on deleted_by.
ALTER TABLE message ADD COLUMN IF NOT EXISTS deleted_at timestamptz;
ALTER TABLE message ADD COLUMN IF NOT EXISTS deleted_by uuid;
70 changes: 70 additions & 0 deletions e2e/forum-replies.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,3 +101,73 @@ test('Function: issueSession — POST /debug/accounts/:id/session with the e2e t
const me = await request.get('/me', { headers: { authorization: `Bearer ${token}` } });
expect(me.status()).toBe(200);
});

test('Function: markDeleted — DELETE /messages/:id hides the note', async ({ request }) => {
const stamp = `${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
const hideName = `E2eHide${stamp.slice(0, 8)}`;
const provision = await request.post('/debug/accounts', {
headers: DEBUG,
data: {
accounts: [
{
name: hideName,
lightningAddress: `e2e-hide-${stamp}@walletofsatoshi.com`,
},
],
},
});
expect(provision.status()).toBe(200);

const listed = await request.get('/debug/accounts', { headers: DEBUG });
expect(listed.status()).toBe(200);
const accounts = ((await listed.json()) as { accounts: Array<{ id: string; name: string }> })
.accounts;
const account = accounts.find((row) => row.name === hideName);
expect(account).toBeDefined();

const session = await request.post(`/debug/accounts/${account?.id}/session`, { headers: DEBUG });
expect(session.status()).toBe(200);
const token = ((await session.json()) as { token: string }).token;
const auth = { authorization: `Bearer ${token}` };
const agreed = await request.post('/me/rules-agreement', { headers: auth });
expect(agreed.status()).toBe(200);

const posted = await request.post('/messages', {
headers: { ...auth, 'content-type': 'application/json' },
data: { text: 'e2e hide me' },
});
expect(posted.status()).toBe(200);
const note = (await posted.json()) as { id: string };

const beforeHide = await request.get(`/messages/${note.id}`);
expect(beforeHide.status()).toBe(200);

const basisDenied = await request.delete(`/messages/${note.id}`, { headers: auth });
expect(basisDenied.status()).toBe(403);
const stillVisible = await request.get(`/messages/${note.id}`);
expect(stillVisible.status()).toBe(200);

const promoted = await request.patch(`/debug/accounts/${account?.id}`, {
headers: DEBUG,
data: { role: 'moderator' },
});
expect(promoted.status()).toBe(200);

const hidden = await request.delete(`/messages/${note.id}`, { headers: auth });
expect(hidden.status()).toBe(204);
expect(await hidden.text()).toBe('');

const afterHide = await request.get(`/messages/${note.id}`);
expect(afterHide.status()).toBe(404);

const list = await request.get('/messages', { headers: auth });
expect(list.status()).toBe(200);
const listedNotes = ((await list.json()) as { messages: Array<{ id: string }> }).messages;
expect(listedNotes.some((row) => row.id === note.id)).toBe(false);

const photo = await request.get(`/messages/${note.id}/photo`);
expect(photo.status()).toBe(404);

const again = await request.delete(`/messages/${note.id}`, { headers: auth });
expect(again.status()).toBe(204);
});
5 changes: 5 additions & 0 deletions e2e/http.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,11 @@ test('GET /messages/:id without bearer is 404 on default boot', async ({ request
expect(res.status()).toBe(404);
});

test('DELETE /messages/:id without bearer is 401', async ({ request }) => {
const res = await request.delete('/messages/:id');
expect(res.status()).toBe(401);
});

test('GET /messages/:id/replies without bearer is 401', async ({ request }) => {
const res = await request.get('/messages/:id/replies');
expect(res.status()).toBe(401);
Expand Down
188 changes: 187 additions & 1 deletion src/__tests__/lib/message-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,12 @@ describe('MESSAGE_SCHEMA_SQL', () => {
expect(MESSAGE_SCHEMA_SQL.join('\n')).toMatch(/account_profile_message_id_fkey/);
expect(MESSAGE_SCHEMA_SQL.join('\n')).toMatch(/ON DELETE SET NULL/);
expect(MESSAGE_SCHEMA_SQL.join('\n')).toMatch(/account_profile_message_uidx/);
expect(MESSAGE_SCHEMA_SQL.join('\n')).toMatch(
/ALTER TABLE message ADD COLUMN IF NOT EXISTS deleted_at timestamptz/,
);
expect(MESSAGE_SCHEMA_SQL.join('\n')).toMatch(
/ALTER TABLE message ADD COLUMN IF NOT EXISTS deleted_by uuid/,
);
});
});

Expand All @@ -133,6 +139,106 @@ describe('InMemoryMessageStore', () => {
expect((await store.listLatest(10)).map((row) => row.id)).toEqual(['b']);
});

it('markDeleted returns false when missing and tags the row plus direct replies', async () => {
const store = new InMemoryMessageStore();
await store.create({ ...EARLY, id: 'p-hide', text: 'parent' }, JPEG);
await store.create({ ...LATE, id: 'c-hide', parentId: 'p-hide', text: 'child' });
await store.create({ ...LATE, id: 'c2-live', parentId: 'other', text: 'other-child' });
const invoice: MessageInvoiceAttempt = {
id: 'inv-hide',
createdAt: new Date('2026-08-01T00:00:00.000Z'),
messageId: 'p-hide',
payerAccountId: 'payer',
authorAccountId: 'author',
amountSats: 21,
lightningAddress: 'a@b.com',
zapRequest: { kind: 9734 },
result: 'ok',
httpStatus: 200,
pr: 'lnbc1',
paymentHash: 'aa'.repeat(32),
description: null,
descriptionHash: 'bb'.repeat(32),
isNip57Invoice: true,
lnurlResponse: null,
};
await store.recordInvoiceAttempt(invoice);
expect(await store.recordZapReceipt('receipt-hide', 'p-hide', 21)).toBe(true);
const at = new Date('2026-09-01T12:00:00.000Z');
expect(await store.markDeleted('missing', at, 'staff')).toBe(false);
expect(await store.markDeleted('p-hide', at, 'staff')).toBe(true);
const parent = await store.getById('p-hide');
const child = await store.getById('c-hide');
expect(parent?.deletedAt?.toISOString()).toBe(at.toISOString());
expect(parent?.deletedBy).toBe('staff');
expect(child?.deletedAt?.toISOString()).toBe(at.toISOString());
expect(child?.deletedBy).toBe('staff');
expect(await store.getPhoto('p-hide')).toEqual(JPEG);
expect((await store.listInvoiceAttempts(10)).map((row) => row.id)).toContain('inv-hide');
expect(await store.recordZapReceipt('receipt-hide', 'p-hide', 1)).toBe(false);
expect((await store.listLatest(10)).map((row) => row.id)).not.toContain('p-hide');
expect(await store.listReplies('p-hide')).toEqual([]);
});

it('markDeleted keeps original stamps on an already-tagged target and stamps live children', async () => {
const store = new InMemoryMessageStore();
const firstAt = new Date('2026-08-01T00:00:00.000Z');
const secondAt = new Date('2026-09-01T00:00:00.000Z');
await store.create({
...EARLY,
id: 'p-retag',
deletedAt: firstAt,
deletedBy: 'first-staff',
});
await store.create({ ...LATE, id: 'c-retag', parentId: 'p-retag', text: 'child' });
expect(await store.markDeleted('p-retag', secondAt, 'second-staff')).toBe(true);
const parent = await store.getById('p-retag');
const child = await store.getById('c-retag');
expect(parent?.deletedAt?.toISOString()).toBe(firstAt.toISOString());
expect(parent?.deletedBy).toBe('first-staff');
expect(child?.deletedAt?.toISOString()).toBe(secondAt.toISOString());
expect(child?.deletedBy).toBe('second-staff');
});

it('replyCount and worker scans omit soft-deleted rows', async () => {
const store = new InMemoryMessageStore();
const eventId = '11'.repeat(32);
await store.create({
...EARLY,
id: 'p-scan',
eventId,
nostrPublishState: 'published',
hasPhoto: true,
text: 'live parent',
});
await store.create({
...LATE,
id: 'c-scan',
parentId: 'p-scan',
text: 'live child',
});
await store.create({
...LATE,
id: 'c-dead',
parentId: 'p-scan',
text: 'dead child',
deletedAt: new Date('2026-09-01T00:00:00.000Z'),
deletedBy: 'staff',
});
const listed = await store.listLatest(10);
expect(listed.find((row) => row.id === 'p-scan')?.replyCount).toBe(1);
expect((await store.listReplies('p-scan')).map((row) => row.id)).toEqual(['c-scan']);
expect(await store.listPublishedEventIds(10)).toEqual([eventId]);
await store.markDeleted('p-scan', new Date('2026-09-02T00:00:00.000Z'), 'staff');
expect(await store.listPublishedEventIds(10)).toEqual([]);
expect(await store.listPendingSigned(10)).toEqual([]);
expect(await store.listSignedMissingPhoto(10)).toEqual([]);
expect(await store.listSignedMissingVideo(10)).toEqual([]);
expect(await store.listSignedMissingHashtags(10)).toEqual([]);
expect(await store.claimUnsigned(10, 1_000, 60_000)).toEqual([]);
expect(await store.claimUnpublished(10, 1_000, 60_000)).toEqual([]);
});

it('deleteById cascades replies, invoices, zap receipts, photo, and video', async () => {
const store = new InMemoryMessageStore();
const mp4 = new Uint8Array(32);
Expand Down Expand Up @@ -1017,8 +1123,9 @@ describe('PostgresMessageStore', () => {
const listed = await store.listLatest(50);
expect(sql.queries[0]?.text).toMatch(/has_photo/);
expect(sql.queries[0]?.text).toMatch(/event_id/);
expect(sql.queries[0]?.text).toMatch(/parent_id IS NULL/);
expect(sql.queries[0]?.text).toMatch(/parent_id IS NULL AND deleted_at IS NULL/);
expect(sql.queries[0]?.text).toMatch(/reply_count/);
expect(sql.queries[0]?.text).toMatch(/child\.deleted_at IS NULL/);
expect(sql.queries[0]?.text).toMatch(/ORDER BY created_at DESC, id DESC\s+LIMIT \$1/);
expect(sql.queries[0]?.text).not.toMatch(/SELECT[^;]*\bphoto\b(?!\s+IS\s+NOT\s+NULL)/i);
expect(sql.queries[0]?.params).toEqual([50]);
Expand Down Expand Up @@ -1235,6 +1342,49 @@ describe('PostgresMessageStore', () => {
expect(sql.executes.some((e) => e.text.includes('sats = sats +'))).toBe(true);
});

it('getById maps deleted_at Date and ISO string', async () => {
const sql = new MockSql();
const deletedAtDate = new Date('2026-09-01T12:00:00.000Z');
sql.nextRows = [
{
id: 'm1',
account_id: 'acc',
name: 'Ada',
text: 'hi',
created_at: new Date(0),
has_photo: false,
event_id: null,
nostr_publish_state: 'pending',
sats: 0,
deleted_at: deletedAtDate,
deleted_by: 'staff-acc',
},
];
const store = new PostgresMessageStore(sql);
const mappedDate = await store.getById('m1');
expect(mappedDate?.deletedAt?.getTime()).toBe(deletedAtDate.getTime());
expect(mappedDate?.deletedBy).toBe('staff-acc');

const deletedAtIso = '2026-09-02T00:00:00.000Z';
sql.nextRows = [
{
id: 'm2',
account_id: 'acc',
name: 'Ada',
text: 'hi',
created_at: new Date(0),
has_photo: false,
event_id: null,
nostr_publish_state: 'pending',
sats: 0,
deleted_at: deletedAtIso,
},
];
const mappedIso = await store.getById('m2');
expect(mappedIso?.deletedAt?.getTime()).toBe(Date.parse(deletedAtIso));
expect(mappedIso?.deletedBy).toBeNull();
});

it('deleteById issues one CTE query for receipts, invoices, and rows', async () => {
const sql = new MockSql();
sql.nextRows = [
Expand Down Expand Up @@ -1262,6 +1412,42 @@ describe('PostgresMessageStore', () => {
expect(sql.queries[0]?.text).toMatch(/WITH/);
});

it('markDeleted issues an UPDATE CTE and returns false when missing', async () => {
const sql = new MockSql();
sql.nextRows = [{ id: 'm1' }];
const at = new Date('2026-09-01T12:00:00.000Z');
expect(await new PostgresMessageStore(sql).markDeleted('m1', at, 'staff')).toBe(true);
expect(sql.executes).toEqual([]);
expect(sql.queries).toHaveLength(1);
const text = sql.queries[0]?.text ?? '';
expect(text).toMatch(/UPDATE message SET deleted_at = \$2, deleted_by = \$3/);
expect(text).toMatch(/deleted_at IS NULL AND \(id = \$1 OR parent_id = \$1\)/);
expect(text).not.toMatch(/DELETE FROM message/);
expect(sql.queries[0]?.params).toEqual(['m1', at, 'staff']);

const missing = new MockSql();
missing.nextRows = [];
expect(await new PostgresMessageStore(missing).markDeleted('gone', at, 'staff')).toBe(false);
});

it('list and claim SQL require deleted_at IS NULL', async () => {
const sql = new MockSql();
sql.nextRows = [];
const store = new PostgresMessageStore(sql);
await store.listLatest(10);
await store.listReplies('p1', 10);
await store.listPublishedEventIds(10);
await store.listPendingSigned(10);
await store.listSignedMissingPhoto(10);
await store.listSignedMissingVideo(10);
await store.listSignedMissingHashtags(10);
await store.claimUnsigned(5, 1_000, 60_000);
await store.claimUnpublished(5, 1_000, 60_000);
for (const query of sql.queries) {
expect(query.text).toMatch(/deleted_at IS NULL/);
}
});

it('propagates create execute errors', async () => {
const sql = new MockSql();
sql.executeError = new Error('create boom');
Expand Down
1 change: 1 addition & 0 deletions src/__tests__/lib/nostr/zap-index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1106,6 +1106,7 @@ describe('indexOpenZapReceipts', () => {
create: (...args: Parameters<InMemoryMessageStore['create']>) => base.create(...args),
getPhoto: (id: string) => base.getPhoto(id),
deleteById: (id: string) => base.deleteById(id),
markDeleted: (id: string, at: Date, by: string) => base.markDeleted(id, at, by),
getById: (id: string) => base.getById(id),
getByEventId: (id: string) => base.getByEventId(id),
claimUnsigned: (...args: Parameters<InMemoryMessageStore['claimUnsigned']>) =>
Expand Down
38 changes: 38 additions & 0 deletions src/__tests__/routes/members.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,44 @@ describe('GET /members/:accountId', () => {
expect(body.profileMessage).toBeNull();
});

it('returns profileMessage null when the profile note is soft-deleted but keeps profileMessageId', async () => {
const authStore = await seededCaller();
const messageStore = new InMemoryMessageStore();
const noteId = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb';
await authStore.createAccount({
id: ACCOUNT_ID,
linkingKey: null,
role: 'verified',
name: 'Ada',
lightningAddress: 'ada@walletofsatoshi.com',
lightningAddressVerified: true,
forumLawsDismissed: false,
viewKey: 'b'.repeat(64),
createdAt: 1_700_000_000_000,
rulesAgreedAt: now(),
profileMessageId: noteId,
});
await messageStore.create({
id: noteId,
accountId: ACCOUNT_ID,
name: 'Ada',
text: 'Ada',
createdAt: new Date(now()),
hasPhoto: false,
...unsignedNostrDefaults(),
eventId: 'ee'.repeat(32),
});
expect(await messageStore.markDeleted(noteId, new Date(now()), 'staff')).toBe(true);
const res = await mount(authStore, messageStore).request(`/members/${ACCOUNT_ID}`, {
headers: AUTH,
});
expect(res.status).toBe(200);
const body = (await res.json()) as { profileMessage: null };
expect(body.profileMessage).toBeNull();
const account = await authStore.getAccount(ACCOUNT_ID);
expect(account?.profileMessageId).toBe(noteId);
});

it('returns 503 when getAccount throws', async () => {
const authStore = await seededCaller();
const original = authStore.getAccount.bind(authStore);
Expand Down
Loading
Loading