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
77 changes: 77 additions & 0 deletions docs/api-spec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
12 changes: 11 additions & 1 deletion src/app/api/v1/_lib/mappers.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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),
Expand Down
38 changes: 38 additions & 0 deletions src/app/api/v1/sessions/public/route.ts
Original file line number Diff line number Diff line change
@@ -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();
}
}
4 changes: 4 additions & 0 deletions src/lib/api-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
45 changes: 45 additions & 0 deletions src/lib/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number>().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<s.HostSession> {
Expand Down