Skip to content
Merged
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: 1 addition & 1 deletion apps/api/gitsheets/event-rsvps.toml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
[gitsheet]
root = 'event-rsvps'
path = '${{ eventSlug }}/${{ personSlug }}'
path = '${{ year }}/${{ eventSlug }}/${{ personSlug }}'
implements = ["codeforphilly.org/event-rsvps/v1"]
5 changes: 4 additions & 1 deletion apps/api/gitsheets/event-series.toml
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
[gitsheet]
root = 'event-series'
path = '${{ slug }}'

implements = ["codeforphilly.org/event-series/v1"]

[gitsheet.format]
type = 'markdown'
body = 'description'
6 changes: 5 additions & 1 deletion apps/api/gitsheets/events.toml
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
[gitsheet]
root = 'events'
path = '${{ slug }}'
path = '${{ startsAt: YYYY }}/${{ slug }}'
implements = ["codeforphilly.org/events/v1"]

[gitsheet.format]
type = 'markdown'
body = 'description'
17 changes: 14 additions & 3 deletions apps/api/src/plugins/legacy-redirect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,13 +107,24 @@ async function legacyRedirectPlugin(fastify: FastifyInstance): Promise<void> {
return;
}

// /events/<handle>/segments/<seg> → /events/<handle>#schedule ------------
// Segments became agenda items on the event (specs/behaviors/events.md#agenda).
// A laddr "event" was a whole program, backfilled as a series
// (specs/behaviors/events.md#legacy-urls):
// /events/<handle>/segments/<seg> → /events/series/<handle>
// /events/<handle> → /events/series/<handle> (when <handle>
// is a live series slug and no event owns it)
const segmentMatch = /^\/events\/([^/*]+)\/segments(\/.*)?$/.exec(path);
if (segmentMatch) {
await sendRedirect(reply, `/events/${segmentMatch[1]}${query}#schedule`);
await sendRedirect(reply, `/events/series/${segmentMatch[1]}${query}`);
return;
}
const programMatch = /^\/events\/([^/*]+)\/?$/.exec(path);
if (programMatch) {
const handle = programMatch[1] as string;
if (state.eventSeriesIdBySlug.has(handle) && !state.eventIdBySlug.has(handle)) {
await sendRedirect(reply, `/events/series/${handle}${query}`);
return;
}
}

// /projects?ID=<n> → /projects/<slug> -------------------------------------
if (path === '/projects' && query) {
Expand Down
3 changes: 2 additions & 1 deletion apps/api/src/services/event-reminders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { StateApply } from '../store/state-apply.js';
import type { Store } from '../store/store.js';
import { eventEmailFacts, rsvpPlanLine } from './event-notify.js';
import { EventService } from './event.js';
import { MARKDOWN_UPSERT } from './event.write.js';

export type ReminderKind = 'week' | 'day';

Expand Down Expand Up @@ -89,7 +90,7 @@ export class EventReminderService {
author: { name: 'cfp-api', email: 'api@codeforphilly.org' },
},
async (tx) => {
await tx.public.events.upsert(marked);
await tx.public.events.upsert(marked, MARKDOWN_UPSERT);
},
);
new StateApply().upsertEvent(marked).apply(this.#d.state, this.#d.fts);
Expand Down
8 changes: 5 additions & 3 deletions apps/api/src/services/event-series.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import type { InMemoryState } from '../store/memory/state.js';
import { StateApply } from '../store/state-apply.js';
import type { DualStoreTx } from '../store/store.js';
import { EventService, isUpcoming } from './event.js';
import { MARKDOWN_UPSERT } from './event.write.js';
import { computeEventSeriesPermissions, type CallerSession, type EventSeriesPermissions } from './permissions.js';
import { renderMarkdown } from './serializers/common.js';
import type { EventSummaryResponse, SeriesRef } from './serializers/event.js';
Expand Down Expand Up @@ -116,7 +117,8 @@ export class EventSeriesService {
id: series.id,
slug: series.slug,
title: series.title,
description: series.description ?? null,
// A body-less markdown record reads back as '' — present it as absent.
description: series.description || null,
descriptionHtml: series.description ? renderMarkdown(series.description).html : null,
eventCount: this.eventsOf(series, caller).length,
createdAt: series.createdAt,
Expand Down Expand Up @@ -150,7 +152,7 @@ export class EventSeriesWriteService {
createdAt: now,
updatedAt: now,
});
await tx.public['event-series'].upsert(series);
await tx.public['event-series'].upsert(series, MARKDOWN_UPSERT);
return { series, stateApply: new StateApply().upsertEventSeries(series) };
}

Expand Down Expand Up @@ -193,7 +195,7 @@ export class EventSeriesWriteService {
await tx.public['slug-history'].upsert(history);
stateApply.renameEventSeriesSlug(existing.slug).upsertSlugHistory(history);
}
await tx.public['event-series'].upsert(updated);
await tx.public['event-series'].upsert(updated, MARKDOWN_UPSERT);
stateApply.upsertEventSeries(updated);
return { series: updated, stateApply };
}
Expand Down
80 changes: 52 additions & 28 deletions apps/api/src/services/event.write.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,30 @@ function nowIso(): string {
return new Date().toISOString();
}

/** Denormalized path-template fields for `event-rsvps/${eventSlug}/${personSlug}`. */
function withRsvpPath(rsvp: EventRsvp, eventSlug: string, personSlug: string): EventRsvp {
return { ...rsvp, eventSlug, personSlug } as EventRsvp;
/**
* The year an event's records are partitioned under on disk
* (specs/behaviors/storage.md#sheet-layout): the UTC year of `startsAt`.
* Every stored timestamp is a UTC `Z` string, so this equals what gitsheets
* renders for the event sheet's own `${{ startsAt: YYYY }}` bucket.
*/
export function partitionYear(startsAt: string): string {
return startsAt.slice(0, 4);
}

/** Denormalized path-template fields for `event-rsvps/${year}/${eventSlug}/${personSlug}`. */
function withRsvpPath(rsvp: EventRsvp, event: Pick<Event, 'slug' | 'startsAt'>, personSlug: string): EventRsvp {
return { ...rsvp, year: partitionYear(event.startsAt), eventSlug: event.slug, personSlug } as EventRsvp;
}

/**
* Events are markdown-bodied records (`description` is the file body —
* specs/behaviors/storage.md#sheet-layout). gitsheets refuses a full-record
* upsert with no body unless told the omission is deliberate; ours always
* carry the whole record, and an event without a description is normal.
* A body-less file reads back as `''`, which the serializers fold to null.
*/
export const MARKDOWN_UPSERT = { allowMissingBody: true } as const;

/** Fields whose change bumps `icsSequence` (specs/behaviors/events.md#calendar-files). */
const CALENDAR_FIELDS = ['title', 'startsAt', 'endsAt', 'venueName', 'venueAddress', 'onlineUrl'] as const;
/** Subset that also emails attendees ("event updated"). */
Expand Down Expand Up @@ -156,7 +175,7 @@ export class EventWriteService {
updatedAt: now,
});

await tx.public.events.upsert(event);
await tx.public.events.upsert(event, MARKDOWN_UPSERT);
return { event, stateApply: new StateApply().upsertEvent(event) };
}

Expand Down Expand Up @@ -195,6 +214,7 @@ export class EventWriteService {
? existing.slug
: this.#resolveSlug(input.slug, base.title, base.startsAt, existing.id);
const slugChanged = newSlug !== existing.slug;
const partitionChanged = partitionYear(base.startsAt) !== partitionYear(existing.startsAt);

const calendarChanged = CALENDAR_FIELDS.some((f) => (existing[f] ?? null) !== (base[f] ?? null));
const changes: EventChange[] = TIME_OR_PLACE_FIELDS.filter(
Expand All @@ -219,35 +239,39 @@ export class EventWriteService {

const stateApply = new StateApply();

if (slugChanged) {
// Delete the old file, write slug history, and move every RSVP file
// under the new event slug in this same commit
// (specs/api/events.md#patch-apieventsslug).
if (slugChanged || partitionChanged) {
// The record's path changed (its slug, or the year it is partitioned
// under): delete the old file and move every RSVP file with it in
// this same commit (specs/api/events.md#patch-apieventsslug,
// specs/behaviors/storage.md#sheet-layout). Slug history only for a
// rename — the partition is invisible in URLs.
await tx.public.events.delete(existing);
const changedAt = nowIso();
const history = {
id: uuidv7(),
entityType: 'event' as const,
oldSlug: existing.slug,
newSlug,
entityId: existing.id,
changedAt,
expiresAt: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString(),
};
await tx.public['slug-history'].upsert(history);
stateApply.renameEventSlug(existing.slug).upsertSlugHistory(history);
if (slugChanged) {
const changedAt = nowIso();
const history = {
id: uuidv7(),
entityType: 'event' as const,
oldSlug: existing.slug,
newSlug,
entityId: existing.id,
changedAt,
expiresAt: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString(),
};
await tx.public['slug-history'].upsert(history);
stateApply.renameEventSlug(existing.slug).upsertSlugHistory(history);
}

for (const rsvpId of this.#state.rsvpsByEvent.get(existing.id) ?? new Set<string>()) {
const rsvp = this.#state.eventRsvps.get(rsvpId);
if (!rsvp) continue;
const personSlug = this.#state.personSlugById.get(rsvp.personId);
if (!personSlug) continue;
await tx.public['event-rsvps'].delete(withRsvpPath(rsvp, existing.slug, personSlug));
await tx.public['event-rsvps'].upsert(withRsvpPath(rsvp, newSlug, personSlug));
await tx.public['event-rsvps'].delete(withRsvpPath(rsvp, existing, personSlug));
await tx.public['event-rsvps'].upsert(withRsvpPath(rsvp, updated, personSlug));
}
}

await tx.public.events.upsert(updated);
await tx.public.events.upsert(updated, MARKDOWN_UPSERT);
stateApply.upsertEvent(updated);
return { event: updated, previous: existing, changes, stateApply };
}
Expand All @@ -257,7 +281,7 @@ export class EventWriteService {
const existing = this.#eventOrThrow(slug);
if (existing.status !== 'draft') throw new ConflictError('Event is not a draft', 'not_draft');
const updated: Event = EventSchema.parse({ ...existing, status: 'scheduled', updatedAt: nowIso() });
await tx.public.events.upsert(updated);
await tx.public.events.upsert(updated, MARKDOWN_UPSERT);
return { event: updated, stateApply: new StateApply().upsertEvent(updated) };
}

Expand All @@ -281,7 +305,7 @@ export class EventWriteService {
icsSequence: (existing.icsSequence ?? 0) + 1,
updatedAt: now,
});
await tx.public.events.upsert(updated);
await tx.public.events.upsert(updated, MARKDOWN_UPSERT);
return { event: updated, stateApply: new StateApply().upsertEvent(updated) };
}

Expand All @@ -298,7 +322,7 @@ export class EventWriteService {
if (!rsvp) continue;
const personSlug = this.#state.personSlugById.get(rsvp.personId);
if (!personSlug) continue;
await tx.public['event-rsvps'].delete(withRsvpPath(rsvp, existing.slug, personSlug));
await tx.public['event-rsvps'].delete(withRsvpPath(rsvp, existing, personSlug));
stateApply.removeEventRsvp(rsvp);
}
await tx.public.events.delete(existing);
Expand Down Expand Up @@ -358,7 +382,7 @@ export class EventWriteService {
createdAt: existing?.createdAt ?? now,
updatedAt: now,
});
await tx.public['event-rsvps'].upsert(withRsvpPath(rsvp, event.slug, person.slug));
await tx.public['event-rsvps'].upsert(withRsvpPath(rsvp, event, person.slug));
return { event, rsvp, created: existing === undefined, stateApply: new StateApply().upsertEventRsvp(rsvp) };
}

Expand All @@ -376,7 +400,7 @@ export class EventWriteService {
const existingId = this.#state.rsvpIdByEventAndPerson.get(rsvpKey(event.id, person.id));
const existing = existingId ? this.#state.eventRsvps.get(existingId) : undefined;
if (!existing) return { removed: false, stateApply: new StateApply() };
await tx.public['event-rsvps'].delete(withRsvpPath(existing, event.slug, person.slug));
await tx.public['event-rsvps'].delete(withRsvpPath(existing, event, person.slug));
return { removed: true, stateApply: new StateApply().removeEventRsvp(existing) };
}

Expand Down
14 changes: 10 additions & 4 deletions apps/api/src/services/person.write.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import { requireAuth } from '../auth/require.js';
import type { SessionContext } from '../auth/middleware.js';
import { applyTagsForEntity, type TagAssignmentInput, type TagNamespace } from './tag.write.js';
import type { PrivateStore } from '../store/private/index.js';
import { partitionYear } from './event.write.js';
import { rekeyAttachment, restoreAttachments, takeAttachments } from './slug-rename.js';

function nowIso(): string {
Expand Down Expand Up @@ -297,12 +298,17 @@ export class PersonWriteService {
}
}

// 3b. Cascade-delete event RSVPs (path: eventSlug/personSlug).
// 3b. Cascade-delete event RSVPs (path: year/eventSlug/personSlug).
for (const rsvpId of this.#state.rsvpsByPerson.get(personId) ?? new Set<string>()) {
const rsvp = this.#state.eventRsvps.get(rsvpId);
const eventSlug = rsvp ? this.#state.events.get(rsvp.eventId)?.slug : undefined;
if (rsvp && eventSlug) {
await tx.public['event-rsvps'].delete({ ...rsvp, eventSlug, personSlug: existing.slug } as typeof rsvp);
const event = rsvp ? this.#state.events.get(rsvp.eventId) : undefined;
if (rsvp && event) {
await tx.public['event-rsvps'].delete({
...rsvp,
year: partitionYear(event.startsAt),
eventSlug: event.slug,
personSlug: existing.slug,
} as typeof rsvp);
stateApply.removeEventRsvp(rsvp);
}
}
Expand Down
3 changes: 2 additions & 1 deletion apps/api/src/services/serializers/event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,8 @@ export function serializeEvent(
): EventResponse {
return {
...serializeEventSummary(event, opts),
description: event.description ?? null,
// A body-less markdown record reads back as '' — present it as absent.
description: event.description || null,
descriptionHtml: event.description ? renderMarkdown(event.description).html : '',
agenda: (event.agenda ?? []).map(serializeAgendaItem),
cancellationNote: event.cancellationNote ?? null,
Expand Down
8 changes: 8 additions & 0 deletions apps/api/tests/event-series.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,15 @@
* Event series and agendas — specs/behaviors/events.md#series, #agenda;
* specs/api/events.md#event-series.
*/
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';

import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import type { FastifyInstance } from 'fastify';
import { openRepo } from 'gitsheets';

const execFileAsync = promisify(execFile);

import { buildApp } from '../src/app.js';
import { mintSessionFor } from '../src/auth/issue.js';
import { createFullDataRepo, createPrivateStorageDir } from './helpers/test-full-repo.js';
Expand Down Expand Up @@ -96,6 +101,9 @@ describe('event series', () => {

const res = await staffJson('POST', '/api/event-series', { title: 'Monthly Hack Nights', description: 'First **Tuesdays**.' });
expect(res.statusCode, res.body).toBe(201);
// Series are markdown-bodied records (specs/behaviors/storage.md#sheet-layout).
const { stdout: tree } = await execFileAsync('git', ['--git-dir', dataRepo.path, 'ls-tree', '-r', '--name-only', 'main']);
expect(tree.split('\n')).toContain('event-series/monthly-hack-nights.md');
expect(res.json().data).toMatchObject({
slug: 'monthly-hack-nights',
title: 'Monthly Hack Nights',
Expand Down
Loading
Loading