diff --git a/src/routes/profiles.ts b/src/routes/profiles.ts index 582972d..9b564d3 100644 --- a/src/routes/profiles.ts +++ b/src/routes/profiles.ts @@ -43,6 +43,7 @@ const profileJsonSchema = { followsCount: { type: 'number' as const }, atprotoPostsCount: { type: 'number' as const }, hasBlueskyProfile: { type: 'boolean' as const }, + accountCreatedAt: { type: ['string', 'null'] as const, format: 'date-time' as const }, communityCount: { type: 'number' as const }, activity: { type: 'object' as const, @@ -416,6 +417,7 @@ export function profileRoutes(): FastifyPluginCallback { followsCount: user.followsCount, atprotoPostsCount: user.atprotoPostsCount, hasBlueskyProfile: user.hasBlueskyProfile, + accountCreatedAt: user.accountCreatedAt?.toISOString() ?? null, communityCount, activity: scopedActivity ?? globalActivity, labels, diff --git a/src/setup/service.ts b/src/setup/service.ts index d990794..1149c21 100644 --- a/src/setup/service.ts +++ b/src/setup/service.ts @@ -1,5 +1,6 @@ import { eq, sql } from 'drizzle-orm' import { communitySettings } from '../db/schema/community-settings.js' +import { users } from '../db/schema/users.js' import type { Database } from '../db/index.js' import { encrypt } from '../lib/encryption.js' import type { Logger } from '../lib/logger.js' @@ -182,6 +183,10 @@ export function createSetupService( return { alreadyInitialized: true } } + // Promote the initializing user to admin in the users table + await db.update(users).set({ role: 'admin' }).where(eq(users.did, did)) + logger.info({ did }, 'User promoted to admin role') + const finalName = row.communityName logger.info({ did, communityName: finalName }, 'Community initialized') diff --git a/tests/unit/routes/profiles.test.ts b/tests/unit/routes/profiles.test.ts index dc403b2..2fc9696 100644 --- a/tests/unit/routes/profiles.test.ts +++ b/tests/unit/routes/profiles.test.ts @@ -63,6 +63,7 @@ function sampleUserRow(overrides?: Record) { lastActiveAt: new Date(TEST_NOW), declaredAge: null, maturityPref: 'safe', + accountCreatedAt: null, followersCount: 0, followsCount: 0, atprotoPostsCount: 0, @@ -341,6 +342,51 @@ describe('profile routes', () => { expect(body.communityCount).toBe(1) }) + it('includes accountCreatedAt when user has one', async () => { + const accountCreatedAt = new Date('2024-06-15T10:30:00.000Z') + selectChain.where.mockResolvedValueOnce([sampleUserRow({ accountCreatedAt })]) + // topic count, reply count, reactions on topics, reactions on replies, votes on topics, votes on replies + selectChain.where.mockResolvedValueOnce([{ count: 0 }]) + selectChain.where.mockResolvedValueOnce([{ count: 0 }]) + selectChain.where.mockResolvedValueOnce([{ count: 0 }]) + selectChain.where.mockResolvedValueOnce([{ count: 0 }]) + selectChain.where.mockResolvedValueOnce([{ count: 0 }]) + selectChain.where.mockResolvedValueOnce([{ count: 0 }]) + // communityCount + selectDistinctChain.where.mockResolvedValueOnce([]) + selectDistinctChain.where.mockResolvedValueOnce([]) + + const response = await app.inject({ + method: 'GET', + url: `/api/users/${TEST_HANDLE}`, + }) + + expect(response.statusCode).toBe(200) + const body = response.json<{ accountCreatedAt: string | null }>() + expect(body.accountCreatedAt).toBe('2024-06-15T10:30:00.000Z') + }) + + it('returns null accountCreatedAt when user has none', async () => { + selectChain.where.mockResolvedValueOnce([sampleUserRow({ accountCreatedAt: null })]) + selectChain.where.mockResolvedValueOnce([{ count: 0 }]) + selectChain.where.mockResolvedValueOnce([{ count: 0 }]) + selectChain.where.mockResolvedValueOnce([{ count: 0 }]) + selectChain.where.mockResolvedValueOnce([{ count: 0 }]) + selectChain.where.mockResolvedValueOnce([{ count: 0 }]) + selectChain.where.mockResolvedValueOnce([{ count: 0 }]) + selectDistinctChain.where.mockResolvedValueOnce([]) + selectDistinctChain.where.mockResolvedValueOnce([]) + + const response = await app.inject({ + method: 'GET', + url: `/api/users/${TEST_HANDLE}`, + }) + + expect(response.statusCode).toBe(200) + const body = response.json<{ accountCreatedAt: string | null }>() + expect(body.accountCreatedAt).toBeNull() + }) + it('returns null for bannerUrl and bio when not set', async () => { selectChain.where.mockResolvedValueOnce([sampleUserRow({ bannerUrl: null, bio: null })]) selectChain.where.mockResolvedValueOnce([{ count: 0 }]) diff --git a/tests/unit/setup/service.test.ts b/tests/unit/setup/service.test.ts index d75c94d..13f5844 100644 --- a/tests/unit/setup/service.test.ts +++ b/tests/unit/setup/service.test.ts @@ -33,8 +33,17 @@ function createMockDb() { values: valuesFn, }) + // Update chain: db.update().set().where() -> Promise + const whereUpdateFn = vi.fn<() => Promise>().mockResolvedValue([]) + const setFn = vi.fn<() => { where: typeof whereUpdateFn }>().mockReturnValue({ + where: whereUpdateFn, + }) + const updateFn = vi.fn<() => { set: typeof setFn }>().mockReturnValue({ + set: setFn, + }) + return { - db: { select: selectFn, insert: insertFn }, + db: { select: selectFn, insert: insertFn, update: updateFn }, mocks: { selectFn, fromFn, @@ -43,6 +52,9 @@ function createMockDb() { valuesFn, onConflictDoUpdateFn, returningFn, + updateFn, + setFn, + whereUpdateFn, }, } } @@ -256,6 +268,33 @@ describe('SetupService', () => { expect(mockPlcDidService.generateDid).not.toHaveBeenCalled() }) + + it('promotes initializing user to admin role in users table', async () => { + mocks.returningFn.mockResolvedValueOnce([ + { communityName: 'Test Forum', communityDid: null }, + ]) + + const result = await service.initialize({ + did: TEST_DID, + communityName: 'Test Forum', + }) + + expect(result).toStrictEqual({ + initialized: true, + adminDid: TEST_DID, + communityName: 'Test Forum', + }) + expect(mocks.updateFn).toHaveBeenCalledOnce() + }) + + it('does not promote user when community is already initialized', async () => { + mocks.returningFn.mockResolvedValueOnce([]) + + const result = await service.initialize({ did: TEST_DID }) + + expect(result).toStrictEqual({ alreadyInitialized: true }) + expect(mocks.updateFn).not.toHaveBeenCalled() + }) }) // =========================================================================