diff --git a/apps/backend/drizzle/0008_keen_sway.sql b/apps/backend/drizzle/0008_keen_sway.sql new file mode 100644 index 0000000..c60acbf --- /dev/null +++ b/apps/backend/drizzle/0008_keen_sway.sql @@ -0,0 +1 @@ +ALTER TABLE "users" ADD COLUMN "presence_visible" boolean DEFAULT true NOT NULL; \ No newline at end of file diff --git a/apps/backend/drizzle/meta/0008_snapshot.json b/apps/backend/drizzle/meta/0008_snapshot.json new file mode 100644 index 0000000..17b61f1 --- /dev/null +++ b/apps/backend/drizzle/meta/0008_snapshot.json @@ -0,0 +1,1010 @@ +{ + "id": "e7c3f7fd-d30f-485f-8487-ac940c3f85e8", + "prevId": "dfbe295d-f8b1-462b-9ad9-e23c4e3fefd2", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.conversation_members": { + "name": "conversation_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "conversation_id": { + "name": "conversation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "last_read_message_id": { + "name": "last_read_message_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "is_muted": { + "name": "is_muted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_archived": { + "name": "is_archived", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "conversation_members_conversation_id_conversations_id_fk": { + "name": "conversation_members_conversation_id_conversations_id_fk", + "tableFrom": "conversation_members", + "tableTo": "conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "conversation_members_user_id_users_id_fk": { + "name": "conversation_members_user_id_users_id_fk", + "tableFrom": "conversation_members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "conversation_members_last_read_message_id_messages_id_fk": { + "name": "conversation_members_last_read_message_id_messages_id_fk", + "tableFrom": "conversation_members", + "tableTo": "messages", + "columnsFrom": [ + "last_read_message_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.conversations": { + "name": "conversations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "type": { + "name": "type", + "type": "conversation_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'dm'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.devices": { + "name": "devices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "identity_public_key": { + "name": "identity_public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_revoked": { + "name": "is_revoked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "devices_user_identity_idx": { + "name": "devices_user_identity_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "identity_public_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "devices_user_id_users_id_fk": { + "name": "devices_user_id_users_id_fk", + "tableFrom": "devices", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.messages": { + "name": "messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "conversation_id": { + "name": "conversation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "sender_id": { + "name": "sender_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "messages_content_search_idx": { + "name": "messages_content_search_idx", + "columns": [ + { + "expression": "to_tsvector('english', \"content\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "messages_conversation_id_conversations_id_fk": { + "name": "messages_conversation_id_conversations_id_fk", + "tableFrom": "messages", + "tableTo": "conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "messages_sender_id_users_id_fk": { + "name": "messages_sender_id_users_id_fk", + "tableFrom": "messages", + "tableTo": "users", + "columnsFrom": [ + "sender_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.one_time_pre_keys": { + "name": "one_time_pre_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "device_id": { + "name": "device_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "key_id": { + "name": "key_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "otp_device_keyid_idx": { + "name": "otp_device_keyid_idx", + "columns": [ + { + "expression": "device_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "one_time_pre_keys_device_id_devices_id_fk": { + "name": "one_time_pre_keys_device_id_devices_id_fk", + "tableFrom": "one_time_pre_keys", + "tableTo": "devices", + "columnsFrom": [ + "device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.signed_pre_keys": { + "name": "signed_pre_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "device_id": { + "name": "device_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "key_id": { + "name": "key_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "signature": { + "name": "signature", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "spk_device_idx": { + "name": "spk_device_idx", + "columns": [ + { + "expression": "device_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "signed_pre_keys_device_id_devices_id_fk": { + "name": "signed_pre_keys_device_id_devices_id_fk", + "tableFrom": "signed_pre_keys", + "tableTo": "devices", + "columnsFrom": [ + "device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.token_transfers": { + "name": "token_transfers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "conversation_id": { + "name": "conversation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "sender_id": { + "name": "sender_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "recipient_address": { + "name": "recipient_address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_contract_id": { + "name": "token_contract_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tx_hash": { + "name": "tx_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "memo": { + "name": "memo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "token_transfers_conversation_id_conversations_id_fk": { + "name": "token_transfers_conversation_id_conversations_id_fk", + "tableFrom": "token_transfers", + "tableTo": "conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "token_transfers_sender_id_users_id_fk": { + "name": "token_transfers_sender_id_users_id_fk", + "tableFrom": "token_transfers", + "tableTo": "users", + "columnsFrom": [ + "sender_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "token_transfers_tx_hash_unique": { + "name": "token_transfers_tx_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "tx_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.treasury_proposals": { + "name": "treasury_proposals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "contract_id": { + "name": "contract_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "proposal_id": { + "name": "proposal_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "treasury_proposal_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "approvals_count": { + "name": "approvals_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "rejections_count": { + "name": "rejections_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "treasury_proposals_contract_proposal_idx": { + "name": "treasury_proposals_contract_proposal_idx", + "columns": [ + { + "expression": "contract_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "proposal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "treasury_proposals_conversation_id_conversations_id_fk": { + "name": "treasury_proposals_conversation_id_conversations_id_fk", + "tableFrom": "treasury_proposals", + "tableTo": "conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_devices": { + "name": "user_devices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "device_name": { + "name": "device_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "device_platform", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "identity_public_key": { + "name": "identity_public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "registration_id": { + "name": "registration_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_devices_user_id_device_id_unique": { + "name": "user_devices_user_id_device_id_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "device_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_devices_user_id_active_idx": { + "name": "user_devices_user_id_active_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"user_devices\".\"revoked_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_devices_user_id_users_id_fk": { + "name": "user_devices_user_id_users_id_fk", + "tableFrom": "user_devices", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "presence_visible": { + "name": "presence_visible", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_username_unique": { + "name": "users_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wallets": { + "name": "wallets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_primary": { + "name": "is_primary", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "wallets_user_id_users_id_fk": { + "name": "wallets_user_id_users_id_fk", + "tableFrom": "wallets", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "wallets_address_unique": { + "name": "wallets_address_unique", + "nullsNotDistinct": false, + "columns": [ + "address" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.conversation_type": { + "name": "conversation_type", + "schema": "public", + "values": [ + "dm", + "group" + ] + }, + "public.device_platform": { + "name": "device_platform", + "schema": "public", + "values": [ + "web", + "ios", + "android" + ] + }, + "public.treasury_proposal_status": { + "name": "treasury_proposal_status", + "schema": "public", + "values": [ + "active", + "approved", + "rejected", + "executed", + "expired" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/backend/drizzle/meta/_journal.json b/apps/backend/drizzle/meta/_journal.json index a58ae36..83106f4 100644 --- a/apps/backend/drizzle/meta/_journal.json +++ b/apps/backend/drizzle/meta/_journal.json @@ -57,6 +57,13 @@ "when": 1782345600000, "tag": "0007_user_devices", "breakpoints": true + }, + { + "idx": 8, + "version": "7", + "when": 1782653959991, + "tag": "0008_keen_sway", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/backend/src/__tests__/users.test.ts b/apps/backend/src/__tests__/users.test.ts index 916bef0..b550f58 100644 --- a/apps/backend/src/__tests__/users.test.ts +++ b/apps/backend/src/__tests__/users.test.ts @@ -69,6 +69,7 @@ describe('GET /users/me', () => { id: 'auth-user-id', username: 'alice', avatarUrl: null, + presenceVisible: true, wallets: MOCK_USER.wallets, createdAt: MOCK_CREATED_AT, } as never); @@ -80,6 +81,7 @@ describe('GET /users/me', () => { id: 'auth-user-id', username: 'alice', avatarUrl: null, + presenceVisible: true, wallets: [ { address: 'GABCDEFG', isPrimary: true }, { address: 'GHIJKLMN', isPrimary: false }, @@ -291,4 +293,82 @@ describe('PATCH /users/me', () => { expect(res.body.username).toBe('new_name'); expect(res.body.avatarUrl).toBe('new_url'); }); + + it('allows updating presenceVisible setting', async () => { + vi.mocked(db.query.users.findFirst).mockResolvedValue({ + id: 'auth-user-id', + presenceVisible: true, + } as any); + + const mockReturning = vi + .fn() + .mockResolvedValue([{ id: 'auth-user-id', username: 'alice', presenceVisible: false }]); + const mockWhere = vi.fn(() => ({ returning: mockReturning })); + const mockSet = vi.fn(() => ({ where: mockWhere })); + vi.mocked(db.update).mockReturnValue({ set: mockSet } as never); + + const res = await request(app) + .patch('/users/me') + .set('Authorization', AUTH_HEADER) + .send({ presenceVisible: false }); + + expect(res.status).toBe(200); + expect(res.body.presenceVisible).toBe(false); + }); + + it('returns 400 when presenceVisible is not a boolean', async () => { + const res = await request(app) + .patch('/users/me') + .set('Authorization', AUTH_HEADER) + .send({ presenceVisible: 'not-a-boolean' }); + + expect(res.status).toBe(400); + expect(res.body.error).toContain('presenceVisible must be a boolean'); + }); +}); + +describe('GET /users/:id/presence', () => { + it('returns 401 when no token is provided', async () => { + const res = await request(app).get('/users/user-uuid-123/presence'); + expect(res.status).toBe(401); + }); + + it('returns 404 when user does not exist', async () => { + vi.mocked(db.query.users.findFirst).mockResolvedValue(undefined); + + const res = await request(app) + .get('/users/unknown-uuid/presence') + .set('Authorization', AUTH_HEADER); + + expect(res.status).toBe(404); + expect(res.body).toEqual({ error: 'User not found' }); + }); + + it('returns online: unknown when presenceVisible is false', async () => { + vi.mocked(db.query.users.findFirst).mockResolvedValue({ + id: 'user-uuid-123', + presenceVisible: false, + } as any); + + const res = await request(app) + .get('/users/user-uuid-123/presence') + .set('Authorization', AUTH_HEADER); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ online: 'unknown' }); + }); + + it('returns online: false when presenceVisible is true but redis is not connected', async () => { + vi.mocked(db.query.users.findFirst).mockResolvedValue({ + id: 'user-uuid-123', + presenceVisible: true, + } as any); + + const res = await request(app) + .get('/users/user-uuid-123/presence') + .set('Authorization', AUTH_HEADER); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ online: false }); + }); }); diff --git a/apps/backend/src/db/schema.ts b/apps/backend/src/db/schema.ts index 9e09e99..6a85ca0 100644 --- a/apps/backend/src/db/schema.ts +++ b/apps/backend/src/db/schema.ts @@ -15,6 +15,7 @@ export const users = pgTable('users', { id: uuid('id').primaryKey().defaultRandom(), username: text('username').unique(), avatarUrl: text('avatar_url'), + presenceVisible: boolean('presence_visible').notNull().default(true), createdAt: timestamp('created_at').notNull().defaultNow(), updatedAt: timestamp('updated_at').notNull().defaultNow(), }); diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index a101c59..3335e09 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -5,7 +5,7 @@ import { createClient } from 'redis'; import dotenv from 'dotenv'; import { eq } from 'drizzle-orm'; import { db } from './db/index.js'; -import { conversationMembers } from './db/schema.js'; +import { conversationMembers, users } from './db/schema.js'; import { socketAuthMiddleware, type AuthSocket } from './middleware/socketAuth.js'; import { registerMessagingHandlers } from './socket/messaging.js'; import { app } from './app.js'; @@ -111,11 +111,19 @@ io.on('connection', async (socket: AuthSocket) => { await socket.join(m.conversationId); } + const user = await db.query.users.findFirst({ + where: eq(users.id, userId), + columns: { presenceVisible: true }, + }); + const presenceVisible = user?.presenceVisible ?? true; + if (appRedis) { await setOnline(appRedis, userId, socket.id); - for (const m of memberships) { - io.to(m.conversationId).volatile.emit('user_online', { userId }); - io.to(m.conversationId).volatile.emit('presence_update', { userId, online: true }); + if (presenceVisible) { + for (const m of memberships) { + io.to(m.conversationId).emit('user_online', { userId }); + io.to(m.conversationId).emit('presence_update', { userId, online: true }); + } } } @@ -134,13 +142,21 @@ io.on('connection', async (socket: AuthSocket) => { if (appRedis) { const fullyOffline = await setOffline(appRedis, userId, socket.id); if (fullyOffline) { - const memberships = await db.query.conversationMembers.findMany({ - where: eq(conversationMembers.userId, userId), - columns: { conversationId: true }, + const user = await db.query.users.findFirst({ + where: eq(users.id, userId), + columns: { presenceVisible: true }, }); - for (const m of memberships) { - io.to(m.conversationId).volatile.emit('user_offline', { userId }); - io.to(m.conversationId).volatile.emit('presence_update', { userId, online: false }); + const presenceVisible = user?.presenceVisible ?? true; + + if (presenceVisible) { + const memberships = await db.query.conversationMembers.findMany({ + where: eq(conversationMembers.userId, userId), + columns: { conversationId: true }, + }); + for (const m of memberships) { + io.to(m.conversationId).emit('user_offline', { userId }); + io.to(m.conversationId).emit('presence_update', { userId, online: false }); + } } } } diff --git a/apps/backend/src/routes/conversations.ts b/apps/backend/src/routes/conversations.ts index 822070f..f0b1252 100644 --- a/apps/backend/src/routes/conversations.ts +++ b/apps/backend/src/routes/conversations.ts @@ -95,7 +95,12 @@ conversationsRouter.get('/', async (req: AuthRequest, res) => { with: { conversation: conversationRelations as never, }, - })) as unknown as Array<{ conversationId: string; conversation: ConversationPayload }>; + })) as unknown as Array<{ + conversationId: string; + conversation: ConversationPayload; + isMuted: boolean; + isArchived: boolean; + }>; // Single subquery for message counts — no N+1 const conversationIds = memberships.map((m) => m.conversationId); diff --git a/apps/backend/src/routes/treasury.ts b/apps/backend/src/routes/treasury.ts index 660f768..3a205c0 100644 --- a/apps/backend/src/routes/treasury.ts +++ b/apps/backend/src/routes/treasury.ts @@ -1,9 +1,9 @@ -import { Router } from 'express'; +import { Router, type Router as RouterType } from 'express'; import { z } from 'zod'; import { requireAuth, type AuthRequest } from '../middleware/auth.js'; import { validate } from '../middleware/validate.js'; -export const treasuryRouter = Router(); +export const treasuryRouter: RouterType = Router(); treasuryRouter.use(requireAuth); diff --git a/apps/backend/src/routes/users.ts b/apps/backend/src/routes/users.ts index 3a7dc6d..c40d949 100644 --- a/apps/backend/src/routes/users.ts +++ b/apps/backend/src/routes/users.ts @@ -2,10 +2,11 @@ import { createHash } from 'node:crypto'; import { Router, type Router as RouterType } from 'express'; import { eq, and, or, ilike, exists, sql } from 'drizzle-orm'; import { db } from '../db/index.js'; -import { users, wallets, devices } from '../db/schema.js'; +import { users, wallets, devices, conversationMembers } from '../db/schema.js'; import { requireAuth, type AuthRequest } from '../middleware/auth.js'; import { redis } from '../lib/redis.js'; import { isOnline } from '../services/presence.js'; +import { getSocketServer } from '../lib/socket.js'; export const usersRouter: RouterType = Router(); @@ -70,6 +71,7 @@ usersRouter.get('/me', async (req: AuthRequest, res) => { id: true, username: true, avatarUrl: true, + presenceVisible: true, createdAt: true, }, with: { @@ -91,6 +93,7 @@ usersRouter.get('/me', async (req: AuthRequest, res) => { id: user.id, username: user.username, avatarUrl: user.avatarUrl, + presenceVisible: user.presenceVisible, wallets: user.wallets.map((w) => ({ address: w.address, isPrimary: w.isPrimary, @@ -144,12 +147,31 @@ usersRouter.get('/:id', async (req: AuthRequest, res) => { usersRouter.get('/:id/presence', async (req: AuthRequest, res) => { const id = req.params['id'] as string; - if (!redis) { - res.json({ online: false }); - return; + try { + const user = await db.query.users.findFirst({ + where: eq(users.id, id), + columns: { presenceVisible: true }, + }); + + if (!user) { + res.status(404).json({ error: 'User not found' }); + return; + } + + if (!user.presenceVisible) { + res.json({ online: 'unknown' }); + return; + } + + if (!redis) { + res.json({ online: false }); + return; + } + const online = await isOnline(redis, id); + res.json({ online }); + } catch { + res.status(404).json({ error: 'User not found' }); } - const online = await isOnline(redis, id); - res.json({ online }); }); /** @@ -252,7 +274,7 @@ usersRouter.get('/:id/key-fingerprint', async (req: AuthRequest, res) => { usersRouter.patch('/me', async (req: AuthRequest, res) => { const userId = req.auth!.userId; - const { username, avatarUrl } = req.body; + const { username, avatarUrl, presenceVisible } = req.body; const updateData: Partial = {}; @@ -260,6 +282,14 @@ usersRouter.patch('/me', async (req: AuthRequest, res) => { updateData.avatarUrl = avatarUrl; } + if (presenceVisible !== undefined) { + if (typeof presenceVisible !== 'boolean') { + res.status(400).json({ error: 'presenceVisible must be a boolean' }); + return; + } + updateData.presenceVisible = presenceVisible; + } + if (username !== undefined) { if (typeof username !== 'string' || !/^[a-zA-Z0-9_]{3,30}$/.test(username)) { res @@ -283,6 +313,11 @@ usersRouter.patch('/me', async (req: AuthRequest, res) => { updateData.updatedAt = new Date(); try { + const oldUser = await db.query.users.findFirst({ + where: eq(users.id, userId), + columns: { presenceVisible: true }, + }); + const [updatedUser] = await db .update(users) .set(updateData) @@ -294,6 +329,28 @@ usersRouter.patch('/me', async (req: AuthRequest, res) => { return; } + if (presenceVisible !== undefined && oldUser && presenceVisible !== oldUser.presenceVisible) { + const io = getSocketServer(); + if (io && redis) { + const memberships = await db.query.conversationMembers.findMany({ + where: eq(conversationMembers.userId, userId), + columns: { conversationId: true }, + }); + const online = await isOnline(redis, userId); + if (online) { + for (const m of memberships) { + if (presenceVisible) { + io.to(m.conversationId).emit('user_online', { userId }); + io.to(m.conversationId).emit('presence_update', { userId, online: true }); + } else { + io.to(m.conversationId).emit('user_offline', { userId }); + io.to(m.conversationId).emit('presence_update', { userId, online: false }); + } + } + } + } + } + res.json(updatedUser); } catch { res.status(409).json({ error: 'Username conflict or database error' });