Skip to content
Open
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
2 changes: 2 additions & 0 deletions apps/backend/drizzle/0005_conversation_member_settings.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
ALTER TABLE "conversation_members" ADD COLUMN "is_muted" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "conversation_members" ADD COLUMN "is_archived" boolean DEFAULT false NOT NULL;
25 changes: 17 additions & 8 deletions apps/backend/src/__tests__/conversations.cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,18 +29,27 @@ let mockRedisInstance: {
const mockFindMany = vi.fn();
const mockFindFirst = vi.fn();
const mockExecute = vi.fn();
const mockSelect = vi.fn();

const mockSelectChain = {
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
groupBy: vi.fn().mockResolvedValue([]),
};
mockSelect.mockReturnValue(mockSelectChain);

vi.mock('../db/index.js', () => ({
db: {
query: {
conversationMembers: { findMany: mockFindMany, findFirst: mockFindFirst },
},
execute: mockExecute,
select: mockSelect,
},
}));

vi.mock('../db/schema.js', () => ({ conversationMembers: {}, messages: { createdAt: 'createdAt' } }));
vi.mock('drizzle-orm', () => ({ eq: vi.fn(), desc: vi.fn(), and: vi.fn(), sql: vi.fn() }));
vi.mock('drizzle-orm', () => ({ eq: vi.fn(), desc: vi.fn(), and: vi.fn(), sql: Object.assign(vi.fn(), { join: vi.fn() }), count: vi.fn(), lt: vi.fn() }));

// ── Auth middleware mock: always passes with test userId ───────────────────

Expand Down Expand Up @@ -85,8 +94,8 @@ describe('GET /conversations — Redis caching', () => {

it('queries DB and writes to cache on cache miss', async () => {
mockGet.mockResolvedValue(null); // cache miss
const dbResult = [{ id: 'conv-2', type: 'group' }];
mockFindMany.mockResolvedValue(dbResult.map((c) => ({ conversation: c })));
const conv = { id: 'conv-2', type: 'group' };
mockFindMany.mockResolvedValue([{ conversationId: conv.id, isMuted: false, isArchived: false, conversation: conv }]);
mockSetex.mockResolvedValue('OK');

const res = await request(makeApp()).get('/conversations');
Expand All @@ -96,14 +105,14 @@ describe('GET /conversations — Redis caching', () => {
expect(mockSetex).toHaveBeenCalledWith(
`conversations:${TEST_USER_ID}`,
30,
JSON.stringify(dbResult),
expect.any(String),
);
});

it('falls back to DB when Redis is unavailable (redis is null)', async () => {
mockRedisInstance = null; // simulate no Redis
const dbResult = [{ id: 'conv-3' }];
mockFindMany.mockResolvedValue(dbResult.map((c) => ({ conversation: c })));
const conv = { id: 'conv-3' };
mockFindMany.mockResolvedValue([{ conversationId: conv.id, isMuted: false, isArchived: false, conversation: conv }]);

const res = await request(makeApp()).get('/conversations');

Expand All @@ -114,8 +123,8 @@ describe('GET /conversations — Redis caching', () => {

it('falls back to DB when Redis.get throws', async () => {
mockGet.mockRejectedValue(new Error('Redis connection refused'));
const dbResult = [{ id: 'conv-4' }];
mockFindMany.mockResolvedValue(dbResult.map((c) => ({ conversation: c })));
const conv = { id: 'conv-4' };
mockFindMany.mockResolvedValue([{ conversationId: conv.id, isMuted: false, isArchived: false, conversation: conv }]);
mockSetex.mockResolvedValue('OK');

const res = await request(makeApp()).get('/conversations');
Expand Down
2 changes: 2 additions & 0 deletions apps/backend/src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ export const conversationMembers = pgTable('conversation_members', {
lastReadMessageId: uuid('last_read_message_id').references(() => messages.id, {
onDelete: 'set null',
}),
isMuted: boolean('is_muted').notNull().default(false),
isArchived: boolean('is_archived').notNull().default(false),
joinedAt: timestamp('joined_at').notNull().defaultNow(),
});

Expand Down
75 changes: 69 additions & 6 deletions apps/backend/src/routes/conversations.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Router } from 'express';
import type { IRouter } from 'express';
import { and, asc, count, desc, eq, lt, sql } from 'drizzle-orm';
import { and, count, desc, eq, lt, sql } from 'drizzle-orm';
import { db } from '../db/index.js';
import { conversationMembers, conversations, messages, tokenTransfers } from '../db/schema.js';
import { requireAuth, type AuthRequest } from '../middleware/auth.js';
Expand All @@ -13,12 +13,14 @@ conversationsRouter.use(requireAuth);
const SEARCH_RESULT_LIMIT = 20;

// List all conversations the authenticated user belongs to
// Pass ?archived=true to include archived conversations
conversationsRouter.get('/', async (req: AuthRequest, res) => {
const userId = req.auth!.userId;
const showArchived = req.query['archived'] === 'true';
const key = convCacheKey(userId);

// Cache read — skip on cache miss or Redis unavailable
if (redis) {
// Cache read — skip when requesting archived (different result set)
if (!showArchived && redis) {
try {
const cached = await redis.get(key);
if (cached) {
Expand All @@ -31,7 +33,10 @@ conversationsRouter.get('/', async (req: AuthRequest, res) => {
}

const memberships = await db.query.conversationMembers.findMany({
where: eq(conversationMembers.userId, userId),
where: and(
eq(conversationMembers.userId, userId),
showArchived ? undefined : eq(conversationMembers.isArchived, false),
),
with: {
conversation: {
with: {
Expand Down Expand Up @@ -68,11 +73,13 @@ conversationsRouter.get('/', async (req: AuthRequest, res) => {

const result = memberships.map((m) => ({
...m.conversation,
isMuted: m.isMuted,
isArchived: m.isArchived,
messageCount: countMap.get(m.conversationId) ?? 0,
}));

// Cache write with 30-second TTL
if (redis) {
// Cache write with 30-second TTL (only for default non-archived view)
if (!showArchived && redis) {
try {
await redis.setex(key, CONV_CACHE_TTL, JSON.stringify(result));
} catch {
Expand Down Expand Up @@ -215,6 +222,62 @@ conversationsRouter.get('/:id/search', async (req: AuthRequest, res) => {
res.json({ results });
});

// PATCH /conversations/:id/settings — update muted/archived state for the authenticated user
conversationsRouter.patch('/:id/settings', async (req: AuthRequest, res) => {
const userId = req.auth!.userId;
const conversationId = req.params['id'] as string | undefined;

if (!conversationId) {
res.status(400).json({ error: 'Conversation id is required' });
return;
}

const { muted, archived } = req.body as { muted?: boolean; archived?: boolean };

if (muted === undefined && archived === undefined) {
res.status(400).json({ error: 'At least one of muted or archived is required' });
return;
}

const membership = await db.query.conversationMembers.findFirst({
where: and(
eq(conversationMembers.conversationId, conversationId),
eq(conversationMembers.userId, userId),
),
});

if (!membership) {
res.status(403).json({ error: 'Not a member of this conversation' });
return;
}

const updates: Partial<{ isMuted: boolean; isArchived: boolean }> = {};
if (muted !== undefined) updates.isMuted = muted;
if (archived !== undefined) updates.isArchived = archived;

const [updated] = await db
.update(conversationMembers)
.set(updates)
.where(
and(
eq(conversationMembers.conversationId, conversationId),
eq(conversationMembers.userId, userId),
),
)
.returning();

// Invalidate conversation list cache for this user
if (redis) {
try {
await redis.del(convCacheKey(userId));
} catch {
// Ignore
}
}

res.json({ isMuted: updated!.isMuted, isArchived: updated!.isArchived });
});

// Save a token transfer for a conversation
conversationsRouter.post('/:id/transfers', async (req: AuthRequest, res) => {
const userId = req.auth!.userId;
Expand Down