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
4 changes: 2 additions & 2 deletions app/src/app/speakers/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ function getTalksOfSpeaker(
talks: TalkWithEventCtx[],
speaker: SpeakerWithTalkIds,
) {
return talks.filter((talk) => speaker.talkIds.includes(talk.id));
return talks.filter((talk) => talk.speakerIds.includes(speaker.id));
}

export default async function SpeakersPage() {
Expand Down Expand Up @@ -88,7 +88,7 @@ export default async function SpeakersPage() {
<ul className="space-y-2">
{getTalksOfSpeaker(talks, speaker).map((talk) => (
<li
key={`${speaker.id}-${talk.id}`}
key={`${speaker.id}-${talk.eventId}-${talk.id}`}
className="list-disc ml-4 sm:ml-6"
>
<Link
Expand Down
104 changes: 104 additions & 0 deletions app/src/lib/speaker-directory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { and, asc, desc, eq, lte } from "drizzle-orm";
import type { PgDatabase, PgQueryResultHKT } from "drizzle-orm/pg-core";
import {
profilesTable,
imagesTable,
talksTable,
talkSpeakersTable,
eventTalksTable,
eventsTable,
} from "./schema";

export async function getSpeakerDirectory(
database: Pick<PgDatabase<PgQueryResultHKT>, "select">,
now = new Date(),
) {
// One relation supplies both the directory and its public talk links.
const rows = await database
.select({
profile: profilesTable,
image: imagesTable,
talk: talksTable,
event: {
id: eventsTable.id,
name: eventsTable.name,
slug: eventsTable.slug,
startDate: eventsTable.startDate,
},
})
.from(profilesTable)
.innerJoin(
talkSpeakersTable,
eq(profilesTable.id, talkSpeakersTable.speakerId),
)
.innerJoin(talksTable, eq(talkSpeakersTable.talkId, talksTable.id))
.innerJoin(eventTalksTable, eq(talksTable.id, eventTalksTable.talkId))
.innerJoin(eventsTable, eq(eventTalksTable.eventId, eventsTable.id))
.leftJoin(imagesTable, eq(profilesTable.image, imagesTable.id))
.where(and(eq(eventsTable.isDraft, false), lte(eventsTable.endDate, now)))
.orderBy(
asc(profilesTable.name),
asc(profilesTable.id),
desc(eventsTable.startDate),
asc(eventsTable.id),
asc(talksTable.id),
);

const speakers = new Map<
string,
{
profile: typeof profilesTable.$inferSelect;
image: typeof imagesTable.$inferSelect | null;
talkIds: string[];
}
>();
const talks = new Map<
string,
{
id: string;
title: string;
description: string;
speakerIds: string[];
eventId: string;
eventName: string;
eventSlug: string;
eventStart: Date;
}
>();

for (const row of rows) {
let speaker = speakers.get(row.profile.id);
if (!speaker) {
speaker = { profile: row.profile, image: row.image, talkIds: [] };
speakers.set(row.profile.id, speaker);
}
if (!speaker.talkIds.includes(row.talk.id))
speaker.talkIds.push(row.talk.id);
// A talk may be presented at more than one event, with several speakers.
const appearanceId = `${row.event.id}:${row.talk.id}`;
let talk = talks.get(appearanceId);
if (!talk) {
talk = {
id: row.talk.id,
title: row.talk.title,
description: row.talk.description,
speakerIds: [],
eventId: row.event.id,
eventName: row.event.name,
eventSlug: row.event.slug,
eventStart: row.event.startDate,
};
talks.set(appearanceId, talk);
}
talk.speakerIds.push(row.profile.id);
}
return {
speakers: [...speakers.values()],
talks: [...talks.values()].sort(
(a, b) =>
b.eventStart.getTime() - a.eventStart.getTime() ||
a.eventId.localeCompare(b.eventId) ||
a.id.localeCompare(b.id),
),
};
}
158 changes: 28 additions & 130 deletions app/src/lib/speakers.ts
Original file line number Diff line number Diff line change
@@ -1,141 +1,39 @@
import { eq, and } from "drizzle-orm";
import { db } from "./db";
import {
profilesTable,
imagesTable,
talksTable,
talkSpeakersTable,
eventTalksTable,
eventsTable,
} from "./schema";
import { Profile, getSocialUrls } from "./profiles";
import { type Profile, getSocialUrls } from "./profiles";
import { signImage } from "./image-signing";
import { getSpeakerDirectory } from "./speaker-directory";

export type Talk = {
id: string;
title: string;
description: string | null;
speakerId: string;
eventId: string;
};

export type TalkWithEventCtx = Talk & {
eventName: string;
eventSlug: string;
eventStart: Date;
};

export type SpeakerWithTalkIds = Profile & {
talkIds: string[];
};
export type TalkWithEventCtx = Awaited<
ReturnType<typeof getSpeakerDirectory>
>["talks"][number];
export type SpeakerWithTalkIds = Profile & { talkIds: string[] };

export async function getSpeakersWithTalks(): Promise<{
speakers: SpeakerWithTalkIds[];
talks: TalkWithEventCtx[];
}> {
// Get all speakers (profiles that have talks)
const speakersQuery = await db
.select({
profile: profilesTable,
image: imagesTable,
talkId: talkSpeakersTable.talkId,
})
.from(profilesTable)
.leftJoin(imagesTable, eq(profilesTable.image, imagesTable.id))
.innerJoin(
talkSpeakersTable,
eq(profilesTable.id, talkSpeakersTable.speakerId),
)
.where(eq(profilesTable.profileType, "member"));

// Get all talks with event context
const talksQuery = await db
.select({
talk: talksTable,
event: eventsTable,
speakerId: talkSpeakersTable.speakerId,
})
.from(talksTable)
.innerJoin(talkSpeakersTable, eq(talksTable.id, talkSpeakersTable.talkId))
.innerJoin(eventTalksTable, eq(talksTable.id, eventTalksTable.talkId))
.innerJoin(eventsTable, eq(eventTalksTable.eventId, eventsTable.id))
.where(eq(eventsTable.isDraft, false));

// Group speakers and their talk IDs
const speakersMap = new Map<
string,
{
profile: any;
image: any;
talkIds: string[];
}
>();

speakersQuery.forEach((row) => {
const speakerId = row.profile.id;
if (!speakersMap.has(speakerId)) {
speakersMap.set(speakerId, {
profile: row.profile,
image: row.image,
talkIds: [],
});
}
speakersMap.get(speakerId)!.talkIds.push(row.talkId);
});

// Convert to final format and presign image URLs
const speakersWithPresignedImages = await Promise.all(
Array.from(speakersMap.values()).map(
async (speakerData): Promise<SpeakerWithTalkIds> => {
const profile = speakerData.profile;
const image = speakerData.image || {
url: "/hero-image-rocket.png",
alt: `${profile.name} profile picture`,
placeholder: null,
width: 400,
height: 400,
};

// Sign the image
const signedImage = await signImage(image);

return {
id: profile.id,
name: profile.name,
image: signedImage,
title: profile.title,
bio: profile.bio,
type: profile.profileType,
socials: getSocialUrls({
twitterHandle: profile.twitterHandle,
linkedinHandle: profile.linkedinHandle,
blueskyHandle: profile.blueskyHandle,
}),
talkIds: speakerData.talkIds,
};
},
const directory = await getSpeakerDirectory(db);
const speakers = await Promise.all(
directory.speakers.map(
async ({ profile, image, talkIds }): Promise<SpeakerWithTalkIds> => ({
id: profile.id,
name: profile.name,
image: await signImage(
image ?? {
url: "/hero-image-rocket.png",
alt: `${profile.name} profile picture`,
placeholder: null,
width: 400,
height: 400,
},
),
title: profile.title,
bio: profile.bio,
type: profile.profileType,
socials: getSocialUrls(profile),
talkIds,
}),
),
);

// Deduplicate talks (since a talk can have multiple speakers, it appears multiple times in talksQuery)
const talksMap = new Map<string, TalkWithEventCtx>();

talksQuery.forEach((row) => {
if (!talksMap.has(row.talk.id)) {
talksMap.set(row.talk.id, {
id: row.talk.id,
title: row.talk.title,
description: row.talk.description,
speakerId: row.speakerId, // This will be the first speaker found, but it's not used in the UI
eventId: row.event.id,
eventName: row.event.name,
eventSlug: row.event.slug,
eventStart: row.event.startDate,
});
}
});

const talks: TalkWithEventCtx[] = Array.from(talksMap.values());

return { speakers: speakersWithPresignedImages, talks };
return { speakers, talks: directory.talks };
}
Loading
Loading