diff --git a/docs/api-spec.yaml b/docs/api-spec.yaml index 6195e4e..3f50c9c 100644 --- a/docs/api-spec.yaml +++ b/docs/api-spec.yaml @@ -85,6 +85,34 @@ components: format: date-time required: [id, topic, goal, status, participant_count, created_at, updated_at] + PublicSessionListItem: + type: object + description: Session object returned by the public discovery endpoint. Extends SessionListItem with a join URL. + properties: + id: + type: string + format: uuid + topic: + type: string + goal: + type: string + status: + type: string + enum: [active] + participant_count: + type: integer + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + join_url: + type: string + format: uri + description: Direct URL for participants to join the session + required: [id, topic, goal, status, participant_count, created_at, updated_at, join_url] + Question: type: object properties: @@ -335,6 +363,55 @@ paths: '401': $ref: '#/components/responses/Unauthorized' + /sessions/public: + get: + summary: List public sessions + description: | + Returns active sessions that hosts have marked as publicly accessible. + No authentication required. Rate-limited by IP address (100 req/min). + Use this endpoint to discover participation opportunities. + operationId: listPublicSessions + tags: [Public] + security: [] + parameters: + - name: q + in: query + schema: + type: string + description: Search sessions by topic or goal + - name: limit + in: query + schema: + type: integer + default: 20 + minimum: 1 + maximum: 100 + description: Number of results per page + - name: offset + in: query + schema: + type: integer + default: 0 + minimum: 0 + description: Number of results to skip + responses: + '200': + description: List of public sessions + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/PublicSessionListItem' + pagination: + $ref: '#/components/schemas/Pagination' + required: [data, pagination] + '429': + $ref: '#/components/responses/RateLimited' + /sessions/{id}: get: summary: Get session diff --git a/src/app/api/v1/_lib/mappers.ts b/src/app/api/v1/_lib/mappers.ts index 69a0e9e..a1b6fd8 100644 --- a/src/app/api/v1/_lib/mappers.ts +++ b/src/app/api/v1/_lib/mappers.ts @@ -1,5 +1,5 @@ import type { HostSession } from '@/lib/schema'; -import type { Session, SessionListItem } from '@/lib/api-types'; +import type { Session, SessionListItem, PublicSessionListItem } from '@/lib/api-types'; export function toSessionListItem(hs: HostSession): SessionListItem { return { @@ -13,6 +13,16 @@ export function toSessionListItem(hs: HostSession): SessionListItem { }; } +export function toPublicSessionListItem( + hs: HostSession, + baseUrl: string, +): PublicSessionListItem { + return { + ...toSessionListItem(hs), + join_url: `${baseUrl}/chat?s=${hs.id}`, + }; +} + export function toSession(hs: HostSession): Session { return { ...toSessionListItem(hs), diff --git a/src/app/api/v1/sessions/public/route.ts b/src/app/api/v1/sessions/public/route.ts new file mode 100644 index 0000000..f1a1626 --- /dev/null +++ b/src/app/api/v1/sessions/public/route.ts @@ -0,0 +1,38 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { checkRateLimit } from '../../_lib/rate-limit'; +import { internalError } from '../../_lib/errors'; +import { toPublicSessionListItem } from '../../_lib/mappers'; +import { listPublicSessions } from '@/lib/db'; + +export async function GET(req: NextRequest) { + // Rate limit by IP (no auth required for this endpoint) + const ip = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || 'unknown'; + const rateLimitResponse = checkRateLimit(`public:${ip}`); + if (rateLimitResponse) return rateLimitResponse; + + try { + const { searchParams } = req.nextUrl; + const q = searchParams.get('q') || undefined; + const limit = Math.min( + Math.max(parseInt(searchParams.get('limit') || '20'), 1), + 100, + ); + const offset = Math.max(parseInt(searchParams.get('offset') || '0'), 0); + + const { sessions, total } = await listPublicSessions({ + search: q, + limit, + offset, + }); + + const baseUrl = process.env.AUTH0_BASE_URL || 'https://app.harmonica.chat'; + + return NextResponse.json({ + data: sessions.map((s) => toPublicSessionListItem(s, baseUrl)), + pagination: { total, limit, offset }, + }); + } catch (error) { + console.error('Error in GET /api/v1/sessions/public:', error); + return internalError(); + } +} diff --git a/src/lib/api-types.ts b/src/lib/api-types.ts index b23fbe4..7dfb65c 100644 --- a/src/lib/api-types.ts +++ b/src/lib/api-types.ts @@ -21,6 +21,10 @@ export interface SessionListItem { updated_at: string; } +export interface PublicSessionListItem extends SessionListItem { + join_url: string; +} + export interface Session extends SessionListItem { critical: string | null; context: string | null; diff --git a/src/lib/db.ts b/src/lib/db.ts index ce8cfcf..7f7d80f 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -176,6 +176,51 @@ export async function listSessionsForUser( } } +export async function listPublicSessions(options: { + search?: string; + limit: number; + offset: number; +}): Promise<{ sessions: s.HostSession[]; total: number }> { + const db = await dbPromise; + + try { + // Find active sessions that hosts have marked as public + let baseQuery = db + .selectFrom(hostTableName) + .innerJoin('permissions', 'permissions.resource_id', `${hostTableName}.id`) + .where('permissions.user_id', '=', 'public') + .where('permissions.resource_type', '=', 'SESSION') + .where(`${hostTableName}.active`, '=', true); + + if (options.search) { + const searchTerm = `%${options.search}%`; + baseQuery = baseQuery.where((eb) => + eb.or([ + eb(`${hostTableName}.topic`, 'ilike', searchTerm), + eb(`${hostTableName}.goal`, 'ilike', searchTerm), + ]), + ); + } + + const countResult = await baseQuery + .select((eb) => eb.fn.countAll().as('count')) + .executeTakeFirst(); + const total = Number(countResult?.count ?? 0); + + const sessions = await baseQuery + .selectAll(hostTableName) + .orderBy(`${hostTableName}.last_edit`, 'desc') + .limit(options.limit) + .offset(options.offset) + .execute(); + + return { sessions, total }; + } catch (error) { + console.error('Error in listPublicSessions:', error); + throw error; + } +} + export async function getHostSessionById( sessionId: string, ): Promise {