Skip to content
Draft
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
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,37 @@ cleanly when a deployment lacks the required operation. See the organizer workfl
The discovery location and `$skill-name` invocation follow the
[official Codex skills documentation](https://developers.openai.com/codex/build-skills#where-codex-loads-local-skills).

### Excel / CSV agenda sync

An event API key can preview and atomically apply a whole spreadsheet change set. Create and update
rows are published immediately; delete rows leave a cancelled record under the same `client_id` so
speaker calendars receive a real cancellation and replaying the same file is a no-op. Room, track,
and format accept either an event-scoped id or the display name an organizer sees in Excel.

The Roman seed gives every scheduled debate a stable client id. Its deterministic
[`first-settlement-session-sync.csv`](docs/examples/first-settlement-session-sync.csv) fixture makes
exactly one create, one update, and one delete:

```bash
export CICERO_API_KEY='<key created under Admin → Integrations>'

curl --fail-with-body \
-H "Authorization: Bearer $CICERO_API_KEY" \
-H 'Content-Type: text/csv' \
--data-binary @docs/examples/first-settlement-session-sync.csv \
'http://localhost:3000/api/v1/events/first-settlement/sessions/sync?dryRun=true'

curl --fail-with-body \
-H "Authorization: Bearer $CICERO_API_KEY" \
-H 'Content-Type: text/csv' \
--data-binary @docs/examples/first-settlement-session-sync.csv \
'http://localhost:3000/api/v1/events/first-settlement/sessions/sync?dryRun=false'
```

The preview and first apply report `created: 1`, `updated: 1`, and `deleted: 1`. A second apply
reports `unchanged: 3` with no writes or calendar notifications. The same endpoint also accepts
`application/json` as `{ "rows": [{ "action": "create", "client_id": "...", ... }] }`.

## Deployment

**Vercel** is where the demo above runs. It is a stock Next build — no adapter, no config beyond
Expand Down
7 changes: 7 additions & 0 deletions app/api/v1/_lib/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,13 @@ export const sessionListQuery = z
})
.strict();

export const sessionSyncQuery = z.object({
dryRun: z
.enum(['true', 'false'])
.optional()
.describe('Defaults to true. Set false only after reviewing the preview.'),
});

export const speakerListQuery = z
.object({
q: queryFilter.optional().describe('Search name, biography, company, role, links, and sessions'),
Expand Down
34 changes: 34 additions & 0 deletions app/api/v1/events/[slug]/sessions/sync/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { invalid } from '@/lib/errors';
import {
parseSessionSyncCsv,
sessionSyncJsonBodySchema,
syncPublishedSessions,
} from '@/lib/services/session-sync';
import { requireApiKey } from '../../../../_lib/auth';
import { handle, json, parseBody, parseQuery, PRIVATE_CACHE } from '../../../../_lib/respond';
import { sessionSyncQuery } from '../../../../_lib/schemas';

export const dynamic = 'force-dynamic';

const CSV_CONTENT_TYPES = new Set(['text/csv', 'application/csv', 'application/vnd.ms-excel']);

export async function POST(request: Request, context: { params: Promise<{ slug: string }> }) {
return handle(async () => {
const { slug } = await context.params;
const key = await requireApiKey(request, slug);
const query = parseQuery(sessionSyncQuery, new URL(request.url));
const contentType = request.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase();

const rows = CSV_CONTENT_TYPES.has(contentType ?? '')
? parseSessionSyncCsv(await request.text())
: contentType === 'application/json'
? (await parseBody(sessionSyncJsonBodySchema, request)).rows
: undefined;
if (!rows) throw invalid('Send the agenda as text/csv or application/json');

const result = await syncPublishedSessions(key.eventId, rows, {
dryRun: query.dryRun !== 'false',
});
return json(result, { headers: PRIVATE_CACHE });
});
}
37 changes: 37 additions & 0 deletions app/api/v1/openapi.json/route.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { appUrl } from '@/lib/env';
import {
sessionSyncJsonBodySchema,
sessionSyncResultSchema,
sessionSyncRowSchema,
} from '@/lib/services/session-sync';
import { toJsonSchema, toParameters, type JsonSchema } from '../_lib/openapi';
import { PUBLIC_CACHE, handle, json } from '../_lib/respond';
import {
Expand All @@ -17,6 +22,7 @@ import {
publicFormSchema,
sessionListQuery,
sessionSchema,
sessionSyncQuery,
speakerListQuery,
speakerProfileSchema,
sponsorListQuery,
Expand Down Expand Up @@ -182,6 +188,9 @@ export function buildSpec(origin = appUrl()): JsonSchema {
NewSubmissionResult: toJsonSchema(createSubmissionResponse),
ProgramReconcileRequest: toJsonSchema(programReconcileBody),
ProgramReconcileResult: toJsonSchema(programReconcileResponse),
SessionSyncRow: toJsonSchema(sessionSyncRowSchema),
SessionSyncRequest: toJsonSchema(sessionSyncJsonBodySchema),
SessionSyncResult: toJsonSchema(sessionSyncResultSchema),
Error: toJsonSchema(errorResponse),
},
},
Expand Down Expand Up @@ -212,6 +221,34 @@ export function buildSpec(origin = appUrl()): JsonSchema {
},
},
},
'/events/{slug}/sessions/sync': {
post: {
tags: ['Program'],
summary: 'Preview or apply an agenda collection sync',
description:
'Requires an API key issued for this event. CSV rows use action and client_id columns. Create and update rows are published; delete rows are retained as cancelled so calendar cancellations and idempotent replay remain possible.',
operationId: 'syncSessions',
security: [{ bearerAuth: [] }],
parameters: [slugParam, ...toParameters(sessionSyncQuery, 'query')],
requestBody: {
required: true,
content: {
'text/csv': {
schema: {
type: 'string',
description:
'Excel-compatible CSV with action, client_id, title, room, starts_at and ends_at columns.',
},
},
'application/json': { schema: ref('SessionSyncRequest') },
},
},
responses: {
'200': okResponse('The preview or applied sync result', ref('SessionSyncResult')),
...errors([401, 409, 422]),
},
},
},
'/events/{slug}/speakers': {
get: {
tags: ['Program'],
Expand Down
1 change: 1 addition & 0 deletions db/migrations/0021_worried_salo.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE "scheduled_session" ADD CONSTRAINT "scheduled_session_event_client_id" UNIQUE("event_id","client_id");
2 changes: 1 addition & 1 deletion db/migrations/meta/0005_snapshot.json
Original file line number Diff line number Diff line change
Expand Up @@ -5617,4 +5617,4 @@
"schemas": {},
"tables": {}
}
}
}
Loading
Loading